diff --git a/.agents/skills/borrow-fighters-repo-atlas/references/repo-map.md b/.agents/skills/borrow-fighters-repo-atlas/references/repo-map.md
index c7e0918..e53a140 100644
--- a/.agents/skills/borrow-fighters-repo-atlas/references/repo-map.md
+++ b/.agents/skills/borrow-fighters-repo-atlas/references/repo-map.md
@@ -33,6 +33,7 @@
| `docs/15-character-combat-matrix.md` | Character identity, archetypes and tuning matrix |
| `docs/16-sprite-combat-viewer-roadmap.md` | Sprite viewer/tooling roadmap for artists and combat alignment |
| `docs/17-visual-scale-and-stage-metrics.md` | Runtime visual scale, fighter size and arena metrics |
+| `docs/18-sprite-studio.md` | External Tauri + React sprite manifest editor |
| `docs/adr/` | Architecture Decision Records |
| `docs/adr/0007-sprite-frame-combat-runtime.md` | Decision for using `frames[].combat` in runtime hit/hurt/projectile origin resolution |
| `docs/templates/` | Reusable proposal/checklist templates |
@@ -63,6 +64,7 @@
| `src/math/` | Small geometry helpers |
| `tests/` | Integration tests for combat, flags and sprites |
| `tools/art/` | Local asset extraction utilities |
+| `tools/sprite-studio/` | Isolated Tauri + React app for editing sprite manifests |
## Assets
diff --git a/README.md b/README.md
index d4684cf..4dfc420 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ A ideia continua sendo evoluir com decisões explícitas, escopo controlado e co
- [`docs/11-sprite-pipeline.md`](docs/11-sprite-pipeline.md): formato candidato para atlas, animações, pivots e metadata de sprites.
- [`docs/16-sprite-combat-viewer-roadmap.md`](docs/16-sprite-combat-viewer-roadmap.md): roadmap do viewer para artistas conferirem atlas, pivot, grade e boxes.
- [`docs/17-visual-scale-and-stage-metrics.md`](docs/17-visual-scale-and-stage-metrics.md): escala visual alvo de personagens, arena e workflow de calibracao.
+- [`docs/18-sprite-studio.md`](docs/18-sprite-studio.md): ferramenta Tauri + React para editar manifestos e atlas fora do loop do jogo.
- [`docs/templates/mood-proposal.md`](docs/templates/mood-proposal.md): molde para proposta de moodboard.
- [`docs/templates/character-concept.md`](docs/templates/character-concept.md): molde para personagem e mecânica.
- [`docs/templates/adr-template.md`](docs/templates/adr-template.md): molde para novas decisões.
@@ -50,6 +51,7 @@ A ideia continua sendo evoluir com decisões explícitas, escopo controlado e co
- [`docs/15-character-combat-matrix.md`](docs/15-character-combat-matrix.md): matriz de identidade mecânica e tuning inicial de Rust, Duke, Go e C.
- [`docs/16-sprite-combat-viewer-roadmap.md`](docs/16-sprite-combat-viewer-roadmap.md): ferramenta isolada para inspecionar sprites e preparar hitbox/hurtbox data-driven.
- [`docs/17-visual-scale-and-stage-metrics.md`](docs/17-visual-scale-and-stage-metrics.md): padrao tecnico de tamanho em tela, escala de sprite e largura de arena.
+- [`docs/18-sprite-studio.md`](docs/18-sprite-studio.md): app desktop externo para artistas editarem `*.sprite.json` com UI propria.
- [`docs/09-ai-collaboration.md`](docs/09-ai-collaboration.md): como Codex, Claude e skills devem navegar o projeto.
- [`AGENTS.md`](AGENTS.md): instruções persistentes para Codex.
- [`CLAUDE.md`](CLAUDE.md): instruções persistentes para Claude Code.
@@ -65,6 +67,7 @@ A ideia continua sendo evoluir com decisões explícitas, escopo controlado e co
- [`docs/adr/0005-data-driven-audio-events.md`](docs/adr/0005-data-driven-audio-events.md): eventos de áudio data-driven com manifesto JSON.
- [`docs/adr/0006-runtime-sprite-scale-and-scene-state.md`](docs/adr/0006-runtime-sprite-scale-and-scene-state.md): escala visual por manifesto e maquina de estados de cenas.
- [`docs/adr/0007-sprite-frame-combat-runtime.md`](docs/adr/0007-sprite-frame-combat-runtime.md): metadata de hitbox/hurtbox por frame no runtime.
+- [`docs/adr/0008-external-sprite-studio-tooling.md`](docs/adr/0008-external-sprite-studio-tooling.md): Sprite Studio externo em Tauri + React, isolado do codigo do jogo.
### GitHub
@@ -174,6 +177,17 @@ cargo run -- --tool sprite-viewer --manifest assets/placeholder/c-fighter.sprite
No Sprite Combat Viewer, use o mouse para inspecionar coordenadas locais do frame, arrastar personagem/dummy e ajustar alças de `frames[].combat`. `N` gera um rascunho de metadata a partir do overlay runtime do golpe selecionado, `Tab` / `Shift+Tab` alterna clip, `Enter` sincroniza clip com golpe, `C` / `Shift+C` alterna personagem de combate, `[` / `]` alterna golpe, `.` / `,` avança ou volta frame, `Espaço` pausa, mouse wheel controla zoom, `0` reseta zoom, `=` / `-` ajusta `scale`, `Setas` ou `Shift+Setas` move o `pivot`, `Ctrl+Setas` ajusta largura/altura do corpo físico, `Ctrl+Shift+Setas` ajusta altura abaixada, `Ctrl+S` salva manifestos de tuning, `O` mostra/esconde dummy, `M` mostra/esconde boxes de combate, `T` mostra/esconde trajetória prevista do projectile, `F5` recarrega manifesto/atlas, `F12` salva screenshot em `target/sprite-viewer-capture.png`, `F9`/`F10` gravam um MP4 local, `G` alterna grade, `P` alterna pivot, `B` alterna bounds, `R` reseta posição e `Esc` volta ao menu quando aberto por `Training`. O padrão de escala fica em [`docs/17-visual-scale-and-stage-metrics.md`](docs/17-visual-scale-and-stage-metrics.md), e o roadmap completo fica em [`docs/16-sprite-combat-viewer-roadmap.md`](docs/16-sprite-combat-viewer-roadmap.md).
+O novo Sprite Studio externo vive em `tools/sprite-studio` e deve substituir o viewer Raylib em uma limpeza propria:
+
+```bash
+cd tools/sprite-studio
+pnpm install
+pnpm build
+pnpm tauri dev
+```
+
+Ele usa Tauri 1.8 + React, edita `*.sprite.json` por UI propria, possui file picker nativo, menu desktop, timeline, paineis colapsaveis, tutorial visual, autosave/backup, snap, guias de escala, presets de combat boxes, validacao do runtime e export de review. Detalhes e pre-requisitos ficam em [`docs/18-sprite-studio.md`](docs/18-sprite-studio.md).
+
Configurações disponíveis em `Versus Setup` e `Options`:
| Preferência | Padrão | Efeito |
diff --git a/docs/08-code-architecture.md b/docs/08-code-architecture.md
index abe2a2e..efefd96 100644
--- a/docs/08-code-architecture.md
+++ b/docs/08-code-architecture.md
@@ -81,20 +81,23 @@ borrow-fighters/
│ ├── mod.rs # Tipos geométricos pequenos do jogo
│ ├── rect.rs # Retângulos de colisão/hitbox
│ └── vec2.rs # Vetores 2D se Raylib Vector2 não bastar
-└── tests/
- ├── cli.rs # Contrato de argumentos de inicialização
- ├── characters.rs # Contrato do registro de personagens
- ├── combat_lab.rs # Estado testável do Combat Lab
- ├── attack_frame_data.rs # Timing de golpes em frames
- ├── move_data.rs # Contrato da tabela MoveSpec
- ├── character_identity_tuning.rs # Intenção mecânica de Rust/Duke/Go/C por dados
- ├── combat_rules.rs # Regras puras de combate e IA
- ├── traditional_moves.rs # High/low/throw e ataques aéreos tradicionais
- ├── cpu_traditional_moves.rs # Cobertura da CPU para golpes tradicionais
- ├── feature_flags.rs # Contrato de flags runtime
- ├── audio_manifest.rs # Contrato do manifesto e roteamento de áudio
- ├── sprite_manifest.rs # Validação do formato JSON de sprites
- └── sprite_selection.rs # Clip escolhido a partir do estado do lutador
+├── tests/
+│ ├── cli.rs # Contrato de argumentos de inicialização
+│ ├── characters.rs # Contrato do registro de personagens
+│ ├── combat_lab.rs # Estado testável do Combat Lab
+│ ├── attack_frame_data.rs # Timing de golpes em frames
+│ ├── move_data.rs # Contrato da tabela MoveSpec
+│ ├── character_identity_tuning.rs # Intenção mecânica de Rust/Duke/Go/C por dados
+│ ├── combat_rules.rs # Regras puras de combate e IA
+│ ├── traditional_moves.rs # High/low/throw e ataques aéreos tradicionais
+│ ├── cpu_traditional_moves.rs # Cobertura da CPU para golpes tradicionais
+│ ├── feature_flags.rs # Contrato de flags runtime
+│ ├── audio_manifest.rs # Contrato do manifesto e roteamento de áudio
+│ ├── sprite_manifest.rs # Validação do formato JSON de sprites
+│ └── sprite_selection.rs # Clip escolhido a partir do estado do lutador
+└── tools/
+ ├── art/ # Utilitarios locais de extracao/ajuste de assets
+ └── sprite-studio/ # App Tauri 1.8 + React isolado para editar manifestos de sprite
```
O diretório `scenes/` ainda deve permanecer simples, sem framework de telas. `ui/` já abriga o overlay de debug do Combat Lab, mas ainda não deve virar um sistema genérico antes de haver HUD e menus suficientes para justificar isso. `characters/` já possui o registro mínimo de personagens, mas ainda deve permanecer simples e orientado a dados. Novos módulos só devem entrar quando reduzirem responsabilidade real dos arquivos atuais.
@@ -127,6 +130,20 @@ Deve expor os módulos internos para testes e exemplos. Regras puras de jogo dev
`src/engine/sprites/combat.rs` tambem fica em `engine` porque depende do formato de sprite, clip, pivot e escala visual. Ele nao depende de Raylib; apenas projeta metadata local do atlas para `Rect`/`Vec2` em coordenadas de mundo para que `game::World` possa usar com fallback.
+### `tools/sprite-studio`
+
+O Sprite Studio e uma ferramenta externa em Tauri 1.8 + React. Ele nao faz parte do pacote Rust do jogo, nao importa structs do runtime e nao deve virar dependencia do `src/`.
+
+Responsabilidades:
+
+- abrir atlas e `*.sprite.json`;
+- editar dados de manifesto;
+- rodar validacao do runtime por comando externo (`cargo test`) sem linkar codigo do jogo;
+- salvar artefatos consumidos pelo jogo;
+- dar UI melhor para artistas e devs ajustarem pivot, escala, hitbox, hurtbox e origem de projectile.
+
+O contrato entre jogo e ferramenta e apenas o arquivo em disco. A decisao esta registrada em [`docs/adr/0008-external-sprite-studio-tooling.md`](adr/0008-external-sprite-studio-tooling.md).
+
### `combat/*`
Deve ser o núcleo mais estável do protótipo. Prioridade:
diff --git a/docs/11-sprite-pipeline.md b/docs/11-sprite-pipeline.md
index f22d860..acec3c8 100644
--- a/docs/11-sprite-pipeline.md
+++ b/docs/11-sprite-pipeline.md
@@ -133,11 +133,34 @@ Assets relacionados ao slice atual:
- `assets/placeholder/arena-java-street.png`
- `assets/placeholder/arena-terminal-compiler-lab.png`
-As ferramentas locais ficam em `tools/art/` e devem ser tratadas como utilitarios de prototipo, nao como pipeline final.
+As ferramentas locais ficam em `tools/art/` e `tools/sprite-studio/`. Scripts em `tools/art/` devem ser tratados como utilitarios de prototipo. O `tools/sprite-studio/` e o app desktop externo para editar manifestos e reduzir a dependencia do viewer Raylib embutido no jogo.
+
+## Sprite Studio
+
+O Sprite Studio vive em [`tools/sprite-studio/`](../tools/sprite-studio) e esta documentado em [`docs/18-sprite-studio.md`](18-sprite-studio.md).
+
+Ele usa Tauri 1.8 + React e nao compartilha codigo com o jogo. O contrato entre ferramenta e runtime e somente o artefato salvo em disco:
+
+- o app edita `*.sprite.json`;
+- o app edita `assets/tuning/character-body-metrics.json`;
+- o jogo carrega `*.sprite.json`;
+- o jogo carrega `assets/tuning/character-body-metrics.json`;
+- testes do jogo validam se o manifesto continua aceito.
+
+Comando:
+
+```bash
+cd tools/sprite-studio
+pnpm install
+pnpm build
+pnpm tauri dev
+```
+
+O Studio oferece file picker nativo, menu desktop, paineis colapsaveis, timeline horizontal, tutorial visual (`F1`), edicao de pivot/scale/boxes/origem, snap, guia de escala visual, presets iniciais de boxes, autosave em `target/sprite-studio-autosave/`, backup em `target/sprite-studio-backups/`, validacao do runtime e export de PNG/JSON para review.
## Sprite Combat Viewer
-O primeiro viewer isolado de sprites vive em:
+O viewer Raylib embutido no jogo continua disponivel temporariamente ate a limpeza dedicada que removera a ferramenta antiga. Ele vive em:
- `src/scenes/sprite_viewer.rs`: estado testavel, carregamento de manifesto, clip/frame atual, playback e drag.
- `src/engine/render/sprite_viewer.rs`: grid, pivot, bounds e desenho do atlas via Raylib.
diff --git a/docs/12-technical-combat-guide.md b/docs/12-technical-combat-guide.md
index 875a610..e1b3b7a 100644
--- a/docs/12-technical-combat-guide.md
+++ b/docs/12-technical-combat-guide.md
@@ -29,6 +29,7 @@ Sempre que um código novo alterar combate, personagens, input de combate, Comba
| Combat Lab render | Orquestra Raylib da cena isolada, sprites, grid e projéteis | [`src/engine/render/combat_lab.rs`](../src/engine/render/combat_lab.rs) | Teste manual via Combat Lab |
| Combat debug UI | Boxes, pivot, dummy, overlay e texto de timing do laboratório | [`src/ui/combat_debug.rs`](../src/ui/combat_debug.rs) | Teste manual via Combat Lab |
| Sprite Combat Viewer | Ferramenta isolada para carregar atlas em runtime, ver grid, pivot, bounds e preparar boxes data-driven | [`src/scenes/sprite_viewer.rs`](../src/scenes/sprite_viewer.rs), [`src/scenes/sprite_viewer/combat_edit.rs`](../src/scenes/sprite_viewer/combat_edit.rs), [`src/engine/render/sprite_viewer.rs`](../src/engine/render/sprite_viewer.rs) | [`tests/sprite_viewer.rs`](../tests/sprite_viewer.rs), teste manual via `--tool sprite-viewer` |
+| Sprite Studio | App externo Tauri 1.8 + React para editar manifestos sem depender de Raylib | [`tools/sprite-studio`](../tools/sprite-studio) | `pnpm build`; `pnpm tauri build --debug`; desktop requer pre-requisitos Tauri |
| Input | Teclado/gamepad para luta, menu, Sprite Viewer e Combat Lab | [`src/engine/input.rs`](../src/engine/input.rs), [`src/engine/gamepad.rs`](../src/engine/gamepad.rs) | [`tests/cli.rs`](../tests/cli.rs), [`tests/feature_flags.rs`](../tests/feature_flags.rs) |
| Sprite runtime | Manifest JSON, clip selection, projeção de `frames[].combat` e desenho por pivot | [`src/engine/sprites/`](../src/engine/sprites), [`src/engine/sprites/combat.rs`](../src/engine/sprites/combat.rs) | [`tests/sprite_manifest.rs`](../tests/sprite_manifest.rs), [`tests/sprite_selection.rs`](../tests/sprite_selection.rs) |
@@ -74,7 +75,7 @@ Hitboxes:
Essa técnica foi escolhida porque é legível, testável sem Raylib e suficiente para o Prototype 0.1. Quando o frame visual declara `frames[].combat`, o runtime projeta esses dados para coordenadas de mundo em [`src/engine/sprites/combat.rs`](../src/engine/sprites/combat.rs). A resolução da luta usa `frames[].combat.hitboxes[]` e `frames[].combat.hurtboxes[]` quando essas listas existem; se estiverem ausentes ou vazias, volta para `MoveSpec.hitbox` e `Fighter::hurtboxes()`. A decisão está registrada em [`docs/adr/0007-sprite-frame-combat-runtime.md`](adr/0007-sprite-frame-combat-runtime.md).
-Rust, Duke, Go e C ja possuem `combat.projectile_origin` no primeiro frame do clip `special`. Esse ponto e projetado por [`src/engine/sprites/combat.rs`](../src/engine/sprites/combat.rs) e usado por [`src/game/world.rs`](../src/game/world.rs) ao criar o projectile, para evitar que o poder nasca desalinhado da mao. Rust `Borrow Jab`, heavy punch e kick ja possuem hitboxes de frame; os valores ainda reproduzem o alcance do `MoveSpec` para migrar com baixo risco. Hitboxes/hurtboxes restantes ainda devem ser calibradas no Sprite Combat Viewer antes de virar dado autoritativo.
+Rust, Duke, Go e C ja possuem `combat.projectile_origin` no primeiro frame do clip `special`. Esse ponto e projetado por [`src/engine/sprites/combat.rs`](../src/engine/sprites/combat.rs) e usado por [`src/game/world.rs`](../src/game/world.rs) ao criar o projectile, para evitar que o poder nasca desalinhado da mao. Rust `Borrow Jab`, heavy punch e kick ja possuem hitboxes de frame; os valores ainda reproduzem o alcance do `MoveSpec` para migrar com baixo risco. Hitboxes/hurtboxes restantes devem ser calibradas no Sprite Studio, com o Sprite Combat Viewer Raylib apenas como ferramenta temporaria ate a limpeza dedicada.
### Escala Visual e Pivot
@@ -97,9 +98,10 @@ O padrao de tamanho em tela fica em [`docs/17-visual-scale-and-stage-metrics.md`
- personagens nao-humanos, como Go, devem mirar a mesma faixa principal quando a diferenca de tamanho nao for parte do gameplay;
- a arena atual tem `896 px` jogaveis, cerca de `11,8` larguras de corpo padrao.
-Use o Sprite Combat Viewer para calibrar:
+Use o Sprite Studio para edicao visual confortavel e validacao de runtime. O Sprite Combat Viewer embutido continua disponivel apenas ate a limpeza dedicada:
```bash
+cd tools/sprite-studio && pnpm tauri dev
cargo run -- --tool sprite-viewer --manifest assets/placeholder/go-fighter.sprite.json --clip idle --character go --move light_punch
cargo run -- --tool sprite-viewer --manifest assets/placeholder/c-fighter.sprite.json --clip idle --character c --move light_punch
```
@@ -353,6 +355,8 @@ Poses atuais:
### Sprite Combat Viewer
+O Sprite Combat Viewer Raylib e ferramenta temporaria. A direcao aprovada em [`docs/adr/0008-external-sprite-studio-tooling.md`](adr/0008-external-sprite-studio-tooling.md) e mover a edicao rica para [`tools/sprite-studio`](../tools/sprite-studio). A paridade operacional ja existe; o viewer embutido deve ser removido em uma mudanca propria.
+
Abrir a ferramenta isolada de sprites:
```bash
diff --git a/docs/16-sprite-combat-viewer-roadmap.md b/docs/16-sprite-combat-viewer-roadmap.md
index f428083..6d9394d 100644
--- a/docs/16-sprite-combat-viewer-roadmap.md
+++ b/docs/16-sprite-combat-viewer-roadmap.md
@@ -2,7 +2,9 @@
## Status
-Em implementacao inicial na branch `tooling/sprite-combat-viewer`.
+Implementado como ferramenta Raylib temporaria. O sucessor e o Sprite Studio externo em Tauri + React, documentado em [`docs/18-sprite-studio.md`](18-sprite-studio.md) e decidido em [`docs/adr/0008-external-sprite-studio-tooling.md`](adr/0008-external-sprite-studio-tooling.md).
+
+O Sprite Studio ja possui paridade operacional para abrir, editar, validar e exportar review de manifestos. O viewer Raylib deve ser removido em uma mudanca propria.
Issue de rastreio: [#15](https://github.com/osdeving/borrow-fighters/issues/15).
@@ -10,7 +12,7 @@ Issue de rastreio: [#15](https://github.com/osdeving/borrow-fighters/issues/15).
Criar uma ferramenta leve para artistas e devs verificarem sprites, pivots, alinhamento visual, escala e, nas proximas fases, hitbox, hurtbox, origem de projectile e dummy de contato sem entrar no fluxo normal de luta.
-O viewer nao deve ser tratado como editor final. Ele e uma ferramenta plugavel de producao para reduzir tentativa e erro ao criar atlas e ajustar manifestos.
+O viewer nao deve ser tratado como editor final. Ele foi uma ferramenta plugavel para reduzir tentativa e erro ao criar atlas e ajustar manifestos. A direcao atual e mover a experiencia de edicao rica para o Sprite Studio externo.
## Por Que Nao Basta o Combat Lab
@@ -27,16 +29,14 @@ O Sprite Combat Viewer fica um nivel antes: ele inspeciona o asset e seus dados.
## Pesquisa de GUI
-Para o primeiro corte, Raylib puro e suficiente: grade, linhas, mouse drag, texto e atalhos de teclado cobrem a inspecao basica.
-
-Se a ferramenta passar a precisar de painel lateral com checkboxes, sliders, listas e inputs editaveis, a opcao candidata e `raygui`, biblioteca immediate-mode criada para o ecossistema raylib. O crate `raylib` 6.0 tambem expoe uma feature `raygui`, entao o caminho tecnico existe sem trocar stack.
+Raylib puro foi suficiente para grade, linhas, mouse drag, texto e atalhos de teclado. A partir do momento em que a ferramenta passou a precisar de painel lateral, checkboxes, sliders, listas, inputs editaveis e fluxo de artista, a decisao mudou para Tauri + React.
Decisao atual:
-- usar Raylib puro no corte inicial;
-- evitar `egui`/`imgui` enquanto a ferramenta couber em primitives;
-- avaliar `raygui` na Fase 2 ou 3 se os controles por tecla ficarem insuficientes;
-- nao salvar arquivos automaticamente antes de termos consenso sobre schema de boxes.
+- manter Raylib viewer apenas ate a branch de remocao dedicada;
+- evoluir UI rica no Sprite Studio;
+- nao introduzir `raygui`, `egui` ou `imgui` dentro do jogo neste momento;
+- remover o viewer Raylib quando o Sprite Studio conseguir salvar manifestos aceitos pelo jogo.
Referencias:
diff --git a/docs/18-sprite-studio.md b/docs/18-sprite-studio.md
new file mode 100644
index 0000000..cd8d4c6
--- /dev/null
+++ b/docs/18-sprite-studio.md
@@ -0,0 +1,164 @@
+# 18 — Sprite Studio
+
+## Status
+
+Corte funcional implementado e validado em `tools/sprite-studio`.
+
+O Sprite Studio e a ferramenta desktop externa para revisar e editar artefatos de sprite do Borrow Fighters. Ele substituira o Sprite Combat Viewer embutido no jogo quando atingir paridade operacional.
+
+O corte atual cobre a paridade operacional necessaria para abrir, ajustar, validar e revisar manifestos de sprite fora do game loop. A remocao do viewer Raylib deve acontecer em um passo separado, para reduzir risco e manter o historico limpo.
+
+## Decisao
+
+A decisao arquitetural fica em [`docs/adr/0008-external-sprite-studio-tooling.md`](adr/0008-external-sprite-studio-tooling.md).
+
+Resumo:
+
+- Tauri 1.8 + React + TypeScript;
+- pnpm como package manager;
+- backend Rust apenas para ler/salvar arquivos locais;
+- sem dependencia de codigo do jogo principal;
+- comunicacao por artefatos: `*.sprite.json`, PNGs e manifestos de tuning.
+
+O Studio usa Tauri 1.8.x nesta fase porque o ambiente WSL/Ubuntu 20.04 do projeto possui GLib/GIO 2.64.6. A cadeia Linux do Tauri 2 puxa WebKitGTK/libsoup3 e exige GLib/GIO 2.70 via `glib-sys`/`gio-sys`, causando falha de `pkg-config`. Tauri 1 usa WebKitGTK/libsoup2 e compila com `libwebkit2gtk-4.0-dev`.
+
+## Como Rodar
+
+```bash
+cd tools/sprite-studio
+pnpm install
+pnpm build
+pnpm tauri dev
+```
+
+Build desktop debug:
+
+```bash
+cd tools/sprite-studio
+pnpm tauri build --debug
+```
+
+No Linux, o Tauri precisa de bibliotecas nativas de WebKitGTK, DBus, SVG e integracao desktop. Em Ubuntu 20.04, o conjunto esperado e:
+
+```bash
+sudo apt-get install -y \
+ libdbus-1-dev \
+ libwebkit2gtk-4.0-dev \
+ librsvg2-dev \
+ libxdo-dev \
+ libayatana-appindicator3-dev \
+ libssl-dev
+```
+
+Em Ubuntu mais novo, a documentacao oficial do Tauri pode indicar `libwebkit2gtk-4.1-dev` em vez de `libwebkit2gtk-4.0-dev`.
+
+## Corte Atual
+
+O primeiro corte ja permite:
+
+- abrir manifestos por caminho relativo ao repo ou file picker nativo;
+- usar presets de Rust, Duke, Go e C;
+- carregar o atlas PNG referenciado pelo manifesto;
+- navegar por clips e frames com lista lateral e timeline horizontal;
+- usar barra de menu desktop (`File`, `Edit`, `View`, `Help`) e toolbar de acoes frequentes;
+- esconder/mostrar browser lateral, inspector e timeline para abrir espaco no canvas;
+- visualizar grid, `trimmed_bounds`, `source_crop`, guia de escala alvo, pivot, hitboxes, hurtboxes e origem de projétil;
+- editar `scale`, `pivot`, `duration_ms`, `projectile_origin`, `hurtboxes` e `hitboxes`;
+- adicionar caixas novas clicando no canvas em modo `Hurtbox` ou `Hitbox`;
+- mover pivot/origem de projétil clicando ou arrastando no canvas;
+- selecionar, mover e redimensionar hitboxes/hurtboxes com alças no canvas, cursor contextual e snap opcional de 1/2/4/8/16 px;
+- controlar zoom pelo slider, por `+`/`-` ou mouse wheel sobre o canvas;
+- desfazer/refazer edições com `Ctrl+Z`, `Ctrl+Shift+Z` ou `Ctrl+Y`;
+- consultar `Help -> Sprite Studio Guide` ou `F1` para ver tutorial com diagramas sobre vertical slice, target range, pivot, hitbox, hurtbox e origem de projectile;
+- validar manifesto antes de salvar, bloqueando erros que o runtime rejeitaria;
+- rodar validacao do runtime por botao, usando `cargo test --test sprite_manifest --test sprite_frames --test sprite_selection --test characters`;
+- aplicar presets por frame para hurtbox em pe, hurtbox abaixado, strike medio, strike baixo, origem de projectile e limpeza de metadata de combate;
+- editar e salvar `assets/tuning/character-body-metrics.json` por personagem;
+- salvar automaticamente rascunhos em `target/sprite-studio-autosave/`;
+- criar backup do manifesto anterior em `target/sprite-studio-backups/` antes de salvar no arquivo real;
+- exportar PNG de review com sprite, pivot, bounds e boxes;
+- exportar JSON de review com frame atual, escala, metricas, validacoes, resultado do runtime e notas;
+- salvar o manifesto JSON de volta no arquivo original.
+
+## Fluxo Recomendado
+
+1. Abrir o manifesto pelo preset ou por `Open Manifest`.
+2. Escolher clip/frame pela lista lateral ou pela timeline.
+3. Ligar `Scale guide` e ajustar `scale`/pivot ate o frame caber no range visual alvo.
+4. Usar `Combat Presets` como rascunho inicial de hurtbox/hitbox/origem.
+5. Refinar no canvas com snap ligado.
+6. Rodar `Run Runtime Validation`.
+7. Exportar PNG e JSON de review para anexar no PR ou na issue de arte.
+
+## Comandos De UI
+
+| Acao | Comando |
+|---|---|
+| Abrir ajuda | `F1` ou `Help -> Sprite Studio Guide` |
+| Salvar manifesto | `Ctrl+S` ou `File -> Save JSON` |
+| Desfazer | `Ctrl+Z` |
+| Refazer | `Ctrl+Shift+Z` ou `Ctrl+Y` |
+| Frame anterior/proximo | `Seta esquerda` / `Seta direita` |
+| Zoom in/out | `+` / `-`, slider de zoom ou mouse wheel sobre o canvas |
+| Criar hurtbox/hitbox | Selecionar modo `Hurtbox`/`Hitbox` e clicar no canvas |
+| Mover pivot/origem/box | Arrastar no canvas |
+| Redimensionar box | Arrastar as alcas da box selecionada |
+| Esconder paineis | `View -> Hide Browser Panel`, `View -> Hide Inspector`, `View -> Hide Timeline` |
+
+## Autosave E Backup
+
+O autosave nao altera o manifesto real. Ele grava o estado sujo atual em:
+
+```text
+target/sprite-studio-autosave/
+```
+
+Ao salvar manualmente o manifesto real, o backend cria primeiro uma copia do arquivo anterior em:
+
+```text
+target/sprite-studio-backups/
+```
+
+Essas pastas ficam dentro de `target/` e nao devem ser versionadas.
+
+## Regra De Isolamento
+
+O Sprite Studio replica tipos de manifesto em TypeScript e comandos de arquivo em Rust dentro de `tools/sprite-studio`. Ele nao importa structs, crates, modulos ou testes do jogo.
+
+Se o schema `borrow-fighters.sprite.v1` mudar, atualize:
+
+- [`docs/11-sprite-pipeline.md`](11-sprite-pipeline.md);
+- os testes do jogo que validam manifestos;
+- os tipos locais do Sprite Studio;
+- pelo menos um manifesto real usado como exemplo.
+
+## Criterio Para Remover O Viewer Do Jogo
+
+O Sprite Combat Viewer dentro do jogo deve continuar disponivel enquanto o Sprite Studio nao fizer tudo que o fluxo atual precisa.
+
+Remover o viewer Raylib somente quando o Sprite Studio conseguir:
+
+- abrir e salvar os manifestos atuais;
+- editar scale, pivot, origem de projectile, hitbox e hurtbox por frame;
+- editar as metricas fisicas `width`, `standing_height` e `crouch_height`;
+- comparar frames contra escala alvo de personagem;
+- suportar fluxo de review de artista sem depender do game loop;
+- gerar JSON que `cargo test --all-targets` aceita.
+
+Quando isso acontecer, remover:
+
+- `src/scenes/sprite_viewer.rs`;
+- `src/scenes/sprite_viewer/`;
+- `src/engine/render/sprite_viewer.rs`;
+- rotas `--tool sprite-viewer`;
+- entrada de menu `Training -> Sprite Viewer`;
+- testes dedicados ao viewer Raylib que deixarem de fazer sentido.
+
+Estado atual: a paridade de features e o smoke test desktop foram validados. O proximo passo e remover o viewer Raylib em uma mudanca propria.
+
+## Proximos Incrementos
+
+- Remover o Sprite Combat Viewer Raylib do jogo, menu e CLI.
+- Adicionar comparacao lado a lado com um personagem de referencia real, se a guia de escala nao for suficiente.
+- Criar presets mais especificos por personagem/golpe quando a identidade de combate estabilizar.
+- Permitir exportar um pacote de review com PNG + JSON juntos.
diff --git a/docs/adr/0008-external-sprite-studio-tooling.md b/docs/adr/0008-external-sprite-studio-tooling.md
new file mode 100644
index 0000000..a3278dc
--- /dev/null
+++ b/docs/adr/0008-external-sprite-studio-tooling.md
@@ -0,0 +1,131 @@
+# ADR 0008 — Sprite Studio externo em Tauri + React
+
+## Status
+
+Aceito.
+
+## Contexto
+
+O Sprite Combat Viewer nasceu dentro do jogo em Rust + Raylib para validar rapidamente atlas, pivot, escala, origem de projétil, hitbox e hurtbox. Esse corte foi útil para provar o formato `*.sprite.json`, mas a ferramenta começou a exigir UI de editor: painéis, timeline, seleção de frames, handles de resize, formulários, zoom, grid, undo/redo e salvamento de artefatos.
+
+Raylib continua adequada para o jogo, Combat Lab e debug visual rápido, mas não é uma boa base para uma ferramenta de produção voltada a artistas.
+
+## Decisão
+
+Criar um aplicativo separado chamado **Sprite Studio** em `tools/sprite-studio`, usando:
+
+- Tauri 1.8 para empacotar uma aplicação desktop leve com backend Rust;
+- React + TypeScript para UI rica, painéis, formulários e canvas/SVG;
+- pnpm como gerenciador de pacotes JavaScript;
+- contratos de JSON duplicados dentro do Sprite Studio, sem dependência de código do jogo principal.
+
+Tauri 1.8 foi escolhido para o corte inicial por compatibilidade com o ambiente WSL/Ubuntu 20.04 do projeto. Esse ambiente fornece GLib/GIO 2.64.6 e `libwebkit2gtk-4.0-dev`; a cadeia Linux do Tauri 2 usa WebKitGTK/libsoup3 e exige GLib/GIO 2.70, quebrando em `pkg-config` via `glib-sys`/`gio-sys`.
+
+Revisitar Tauri 2 quando o baseline de desenvolvimento migrar para uma distro com GLib/GIO >= 2.70 ou quando o projeto decidir empacotar o Studio em um ambiente controlado.
+
+O jogo e o Sprite Studio se comunicam apenas por artefatos de disco:
+
+- `*.sprite.json`;
+- atlas PNG;
+- imagens de projétil;
+- manifestos de tuning quando aplicável.
+
+Não haverá crate compartilhado, import de structs do jogo, workspace comum nem dependência de runtime entre os dois projetos nesta fase.
+
+## Consequências
+
+### Positivas
+
+- A UI de editor deixa de disputar espaço com o game loop.
+- Artistas podem usar uma ferramenta dedicada para inspecionar atlas, grid, pivot, hitbox, hurtbox e origem de projétil.
+- O jogo permanece focado em runtime e combate.
+- O formato de artefatos continua simples e auditável.
+- O isolamento reduz acoplamento entre protótipo jogável e tooling.
+
+### Negativas
+
+- Alguns contratos de manifesto serão duplicados.
+- Mudanças no schema exigem atualização explícita no jogo e no Sprite Studio.
+- O repositório passa a ter uma segunda stack: Rust/Raylib no jogo e Tauri/React no tooling.
+- Validações precisam garantir que o Sprite Studio não gere JSON que o jogo rejeita.
+
+## Regra De Isolamento
+
+O Sprite Studio pode ler e escrever arquivos do repositório, mas não pode depender de módulos Rust, structs, crates internos ou lógica compilada do jogo. Se o schema evoluir, a sincronização deve acontecer por documentação, testes e exemplos de manifesto.
+
+## Migração Do Viewer Antigo
+
+O Sprite Combat Viewer embutido no jogo fica temporariamente disponível enquanto o Sprite Studio ainda não tiver paridade operacional.
+
+Critério mínimo para remover o viewer do jogo:
+
+- abrir um `*.sprite.json` existente;
+- carregar o atlas referenciado;
+- navegar por clips e frames;
+- visualizar grid, frame bounds, pivot, escala, hitboxes, hurtboxes e origem de projétil;
+- editar e salvar `scale`, `pivot`, `frames[].combat.hitboxes`, `frames[].combat.hurtboxes` e `frames[].combat.projectile_origin`;
+- validar que o jogo carrega o manifesto salvo;
+- documentar o fluxo para artistas.
+
+Quando esses critérios forem atendidos, remover o código de Sprite Viewer de `src/scenes`, `src/engine/render` e rotas de menu/CLI relacionadas, mantendo o Combat Lab no jogo.
+
+## Alternativas Consideradas
+
+### Manter tudo em Raylib
+
+Prós:
+
+- uma stack só;
+- acesso direto ao rendering do jogo.
+
+Contras:
+
+- UI complexa demais para Raylib;
+- painéis e formulários viram código manual;
+- ferramenta de artista fica presa ao loop do jogo.
+
+### egui/eframe
+
+Prós:
+
+- Rust puro;
+- boa UI imediata para ferramentas.
+
+Contras:
+
+- menos familiar para colaboradores de frontend;
+- layout e edição visual rica tendem a exigir mais código customizado.
+
+### Electron
+
+Prós:
+
+- ecossistema maduro;
+- React muito bem suportado.
+
+Contras:
+
+- runtime mais pesado;
+- menos alinhado ao desejo de manter Rust no backend local.
+
+### C# + Windows Forms
+
+Prós:
+
+- rápido para UI desktop simples no Windows.
+
+Contras:
+
+- amarra a ferramenta a Windows;
+- cria stack distante do restante do projeto;
+- pior para colaboração cross-platform.
+
+## Critério De Revisão
+
+Revisar esta decisão se:
+
+- Tauri dificultar o acesso a arquivos locais no fluxo real de artistas;
+- o baseline Linux do projeto deixar de precisar de Ubuntu 20.04/GLib 2.64;
+- o Sprite Studio exigir rendering idêntico ao jogo em nível de shader/runtime;
+- a duplicação de schema virar fonte frequente de bugs;
+- o projeto decidir migrar para uma engine com editor visual próprio.
diff --git a/tools/sprite-studio/.gitignore b/tools/sprite-studio/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/tools/sprite-studio/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/tools/sprite-studio/README.md b/tools/sprite-studio/README.md
new file mode 100644
index 0000000..19be0da
--- /dev/null
+++ b/tools/sprite-studio/README.md
@@ -0,0 +1,70 @@
+# Borrow Fighters Sprite Studio
+
+Desktop tool for inspecting and editing Borrow Fighters `*.sprite.json` files outside the Raylib game loop.
+
+This app is intentionally isolated from the main game code. It can read and write repository artifacts, but it must not import Rust modules, structs, crates or tests from the game.
+
+This cut uses Tauri 1.8 because the current WSL/Ubuntu 20.04 baseline ships GLib/GIO 2.64.6. Tauri 2 currently pulls the Linux WebKitGTK/libsoup3 path that requires GLib/GIO 2.70.
+
+## Commands
+
+```bash
+pnpm install
+pnpm build
+pnpm tauri dev
+pnpm tauri build --debug
+```
+
+## Linux Prerequisites
+
+Tauri requires native desktop libraries. On Ubuntu 20.04:
+
+```bash
+sudo apt-get install -y \
+ libdbus-1-dev \
+ libwebkit2gtk-4.0-dev \
+ librsvg2-dev \
+ libxdo-dev \
+ libayatana-appindicator3-dev \
+ libssl-dev
+```
+
+Newer Ubuntu releases may use `libwebkit2gtk-4.1-dev`; check the official Tauri prerequisite docs for the local OS.
+
+## Current Scope
+
+- Open a sprite manifest by repository-relative path or native file picker.
+- Use Rust, Duke, Go and C presets.
+- Render the selected atlas frame.
+- Navigate clips and frames through the side list and horizontal timeline.
+- Use desktop-style File/Edit/View/Help menus and a toolbar.
+- Collapse the browser panel, inspector and timeline when the canvas needs space.
+- Inspect grid, frame bounds, visual scale target, pivot, hitboxes, hurtboxes and projectile origin.
+- Edit `scale`, `pivot`, `duration_ms`, `projectile_origin`, `hitboxes` and `hurtboxes`.
+- Drag pivot, projectile origin and boxes directly on the canvas.
+- Resize boxes through visual handles, hover cursors and optional snap.
+- Zoom with the slider, `+`/`-` or mouse wheel over the canvas.
+- Seed initial combat boxes for standing/crouch hurtbox, mid/low strike and projectile origin.
+- Undo/redo local edits.
+- Open the built-in guide with `F1` or Help.
+- Autosave dirty work to `target/sprite-studio-autosave/`.
+- Backup the previous manifest to `target/sprite-studio-backups/` before manual saves.
+- Edit `assets/tuning/character-body-metrics.json`.
+- Run runtime validation through the repository `cargo test` contract.
+- Export a PNG review image.
+- Export a JSON review package with notes, selected frame data and validation output.
+- Save the manifest JSON back to disk.
+
+## Artifact Contract
+
+The Sprite Studio produces files that the game consumes:
+
+- `assets/placeholder/*.sprite.json`
+- PNG atlas files referenced by `image`
+
+Run the game test suite after saving production changes:
+
+```bash
+cd ../..
+cargo test --all-targets
+```
diff --git a/tools/sprite-studio/index.html b/tools/sprite-studio/index.html
new file mode 100644
index 0000000..73491b7
--- /dev/null
+++ b/tools/sprite-studio/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Borrow Fighters Sprite Studio
+
+
+
+
+
+
+
diff --git a/tools/sprite-studio/package.json b/tools/sprite-studio/package.json
new file mode 100644
index 0000000..152f6fd
--- /dev/null
+++ b/tools/sprite-studio/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "borrow-fighters-sprite-studio",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "tauri": "tauri"
+ },
+ "dependencies": {
+ "@tauri-apps/api": "1.6.0",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@tauri-apps/cli": "1.6.3",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "^4.6.0",
+ "typescript": "~5.8.3",
+ "vite": "^7.0.4"
+ }
+}
diff --git a/tools/sprite-studio/pnpm-lock.yaml b/tools/sprite-studio/pnpm-lock.yaml
new file mode 100644
index 0000000..2c0b735
--- /dev/null
+++ b/tools/sprite-studio/pnpm-lock.yaml
@@ -0,0 +1,1250 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@tauri-apps/api':
+ specifier: 1.6.0
+ version: 1.6.0
+ react:
+ specifier: ^19.1.0
+ version: 19.2.7
+ react-dom:
+ specifier: ^19.1.0
+ version: 19.2.7(react@19.2.7)
+ devDependencies:
+ '@tauri-apps/cli':
+ specifier: 1.6.3
+ version: 1.6.3
+ '@types/react':
+ specifier: ^19.1.8
+ version: 19.2.17
+ '@types/react-dom':
+ specifier: ^19.1.6
+ version: 19.2.3(@types/react@19.2.17)
+ '@vitejs/plugin-react':
+ specifier: ^4.6.0
+ version: 4.7.0(vite@7.3.5)
+ typescript:
+ specifier: ~5.8.3
+ version: 5.8.3
+ vite:
+ specifier: ^7.0.4
+ version: 7.3.5
+
+packages:
+
+ '@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-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.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/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-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'}
+
+ '@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]
+
+ '@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==}
+
+ '@rolldown/pluginutils@1.0.0-beta.27':
+ resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
+
+ '@rollup/rollup-android-arm-eabi@4.62.2':
+ resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.62.2':
+ resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.62.2':
+ resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.62.2':
+ resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.62.2':
+ resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.62.2':
+ resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
+ resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.2':
+ resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
+ cpu: [arm]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.2':
+ resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-arm64-musl@4.62.2':
+ resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.2':
+ resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-loong64-musl@4.62.2':
+ resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.2':
+ resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.2':
+ resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.2':
+ resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.2':
+ resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.2':
+ resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-gnu@4.62.2':
+ resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-linux-x64-musl@4.62.2':
+ resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@rollup/rollup-openbsd-x64@4.62.2':
+ resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.62.2':
+ resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.2':
+ resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.2':
+ resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.62.2':
+ resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.62.2':
+ resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@tauri-apps/api@1.6.0':
+ resolution: {integrity: sha512-rqI++FWClU5I2UBp4HXFvl+sBWkdigBkxnpJDQUWttNyG7IZP4FwQGhTNL5EOw0vI8i6eSAJ5frLqO7n7jbJdg==}
+ engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
+
+ '@tauri-apps/cli-darwin-arm64@1.6.3':
+ resolution: {integrity: sha512-fQN6IYSL8bG4NvkdKE4sAGF4dF/QqqQq4hOAU+t8ksOzHJr0hUlJYfncFeJYutr/MMkdF7hYKadSb0j5EE9r0A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tauri-apps/cli-darwin-x64@1.6.3':
+ resolution: {integrity: sha512-1yTXZzLajKAYINJOJhZfmMhCzweHSgKQ3bEgJSn6t+1vFkOgY8Yx4oFgWcybrrWI5J1ZLZAl47+LPOY81dLcyA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tauri-apps/cli-linux-arm-gnueabihf@1.6.3':
+ resolution: {integrity: sha512-CjTEr9r9xgjcvos09AQw8QMRPuH152B1jvlZt4PfAsyJNPFigzuwed5/SF7XAd8bFikA7zArP4UT12RdBxrx7w==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tauri-apps/cli-linux-arm64-gnu@1.6.3':
+ resolution: {integrity: sha512-G9EUUS4M8M/Jz1UKZqvJmQQCKOzgTb8/0jZKvfBuGfh5AjFBu8LHvlFpwkKVm1l4951Xg4ulUp6P9Q7WRJ9XSA==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tauri-apps/cli-linux-arm64-musl@1.6.3':
+ resolution: {integrity: sha512-MuBTHJyNpZRbPVG8IZBN8+Zs7aKqwD22tkWVBcL1yOGL4zNNTJlkfL+zs5qxRnHlUsn6YAlbW/5HKocfpxVwBw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tauri-apps/cli-linux-x64-gnu@1.6.3':
+ resolution: {integrity: sha512-Uvi7M+NK3tAjCZEY1WGel+dFlzJmqcvu3KND+nqa22762NFmOuBIZ4KJR/IQHfpEYqKFNUhJfCGnpUDfiC3Oxg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tauri-apps/cli-linux-x64-musl@1.6.3':
+ resolution: {integrity: sha512-rc6B342C0ra8VezB/OJom9j/N+9oW4VRA4qMxS2f4bHY2B/z3J9NPOe6GOILeg4v/CV62ojkLsC3/K/CeF3fqQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tauri-apps/cli-win32-arm64-msvc@1.6.3':
+ resolution: {integrity: sha512-cSH2qOBYuYC4UVIFtrc1YsGfc5tfYrotoHrpTvRjUGu0VywvmyNk82+ZsHEnWZ2UHmu3l3lXIGRqSWveLln0xg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tauri-apps/cli-win32-ia32-msvc@1.6.3':
+ resolution: {integrity: sha512-T8V6SJQqE4PSWmYBl0ChQVmS6AR2hXFHURH2DwAhgSGSQ6uBXgwlYFcfIeQpBQA727K2Eq8X2hGfvmoySyHMRw==}
+ engines: {node: '>= 10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@tauri-apps/cli-win32-x64-msvc@1.6.3':
+ resolution: {integrity: sha512-HUkWZ+lYHI/Gjkh2QjHD/OBDpqLVmvjZGpLK9losur1Eg974Jip6k+vsoTUxQBCBDfj30eDBct9E1FvXOspWeg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tauri-apps/cli@1.6.3':
+ resolution: {integrity: sha512-q46umd6QLRKDd4Gg6WyZBGa2fWvk0pbeUA5vFomm4uOs1/17LIciHv2iQ4UD+2Yv5H7AO8YiE1t50V0POiEGEw==}
+ engines: {node: '>= 10'}
+ hasBin: true
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@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==}
+
+ '@vitejs/plugin-react@4.7.0':
+ resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ baseline-browser-mapping@2.10.38:
+ resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==}
+ 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
+
+ caniuse-lite@1.0.30001799:
+ resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ electron-to-chromium@1.5.377:
+ resolution: {integrity: sha512-cH1jZgJHoezfTnKfKwnScpHywTFVnJUNITDPREFdhNjiuD502+QFpG0Qk7G8jhsV/f+CEAFlIrzP1fT+IMb92g==}
+
+ 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'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ 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.48:
+ resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==}
+ engines: {node: '>=18'}
+
+ 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}
+
+ react-dom@19.2.7:
+ resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
+ peerDependencies:
+ react: ^19.2.7
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react@19.2.7:
+ resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
+ engines: {node: '>=0.10.0'}
+
+ rollup@4.62.2:
+ resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.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
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ typescript@5.8.3:
+ resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ update-browserslist-db@1.2.3:
+ resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ vite@7.3.5:
+ resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.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
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+snapshots:
+
+ '@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-plugin-utils@7.29.7': {}
+
+ '@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/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 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
+
+ '@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
+
+ '@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
+
+ '@rolldown/pluginutils@1.0.0-beta.27': {}
+
+ '@rollup/rollup-android-arm-eabi@4.62.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.62.2':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.62.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.62.2':
+ optional: true
+
+ '@tauri-apps/api@1.6.0': {}
+
+ '@tauri-apps/cli-darwin-arm64@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-darwin-x64@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-linux-arm-gnueabihf@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-linux-arm64-gnu@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-linux-arm64-musl@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-linux-x64-gnu@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-linux-x64-musl@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-win32-arm64-msvc@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-win32-ia32-msvc@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli-win32-x64-msvc@1.6.3':
+ optional: true
+
+ '@tauri-apps/cli@1.6.3':
+ dependencies:
+ semver: 7.8.5
+ optionalDependencies:
+ '@tauri-apps/cli-darwin-arm64': 1.6.3
+ '@tauri-apps/cli-darwin-x64': 1.6.3
+ '@tauri-apps/cli-linux-arm-gnueabihf': 1.6.3
+ '@tauri-apps/cli-linux-arm64-gnu': 1.6.3
+ '@tauri-apps/cli-linux-arm64-musl': 1.6.3
+ '@tauri-apps/cli-linux-x64-gnu': 1.6.3
+ '@tauri-apps/cli-linux-x64-musl': 1.6.3
+ '@tauri-apps/cli-win32-arm64-msvc': 1.6.3
+ '@tauri-apps/cli-win32-ia32-msvc': 1.6.3
+ '@tauri-apps/cli-win32-x64-msvc': 1.6.3
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.7
+
+ '@types/estree@1.0.9': {}
+
+ '@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
+
+ '@vitejs/plugin-react@4.7.0(vite@7.3.5)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-beta.27
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 7.3.5
+ transitivePeerDependencies:
+ - supports-color
+
+ baseline-browser-mapping@2.10.38: {}
+
+ browserslist@4.28.4:
+ dependencies:
+ baseline-browser-mapping: 2.10.38
+ caniuse-lite: 1.0.30001799
+ electron-to-chromium: 1.5.377
+ node-releases: 2.0.48
+ update-browserslist-db: 1.2.3(browserslist@4.28.4)
+
+ caniuse-lite@1.0.30001799: {}
+
+ convert-source-map@2.0.0: {}
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ electron-to-chromium@1.5.377: {}
+
+ 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
+
+ escalade@3.2.0: {}
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.15: {}
+
+ node-releases@2.0.48: {}
+
+ 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
+
+ react-dom@19.2.7(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ scheduler: 0.27.0
+
+ react-refresh@0.17.0: {}
+
+ react@19.2.7: {}
+
+ rollup@4.62.2:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.62.2
+ '@rollup/rollup-android-arm64': 4.62.2
+ '@rollup/rollup-darwin-arm64': 4.62.2
+ '@rollup/rollup-darwin-x64': 4.62.2
+ '@rollup/rollup-freebsd-arm64': 4.62.2
+ '@rollup/rollup-freebsd-x64': 4.62.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.62.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.62.2
+ '@rollup/rollup-linux-arm64-gnu': 4.62.2
+ '@rollup/rollup-linux-arm64-musl': 4.62.2
+ '@rollup/rollup-linux-loong64-gnu': 4.62.2
+ '@rollup/rollup-linux-loong64-musl': 4.62.2
+ '@rollup/rollup-linux-ppc64-gnu': 4.62.2
+ '@rollup/rollup-linux-ppc64-musl': 4.62.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.62.2
+ '@rollup/rollup-linux-riscv64-musl': 4.62.2
+ '@rollup/rollup-linux-s390x-gnu': 4.62.2
+ '@rollup/rollup-linux-x64-gnu': 4.62.2
+ '@rollup/rollup-linux-x64-musl': 4.62.2
+ '@rollup/rollup-openbsd-x64': 4.62.2
+ '@rollup/rollup-openharmony-arm64': 4.62.2
+ '@rollup/rollup-win32-arm64-msvc': 4.62.2
+ '@rollup/rollup-win32-ia32-msvc': 4.62.2
+ '@rollup/rollup-win32-x64-gnu': 4.62.2
+ '@rollup/rollup-win32-x64-msvc': 4.62.2
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ semver@7.8.5: {}
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ typescript@5.8.3: {}
+
+ update-browserslist-db@1.2.3(browserslist@4.28.4):
+ dependencies:
+ browserslist: 4.28.4
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ vite@7.3.5:
+ dependencies:
+ esbuild: 0.27.7
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rollup: 4.62.2
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ yallist@3.1.1: {}
diff --git a/tools/sprite-studio/src-tauri/.gitignore b/tools/sprite-studio/src-tauri/.gitignore
new file mode 100644
index 0000000..b21bd68
--- /dev/null
+++ b/tools/sprite-studio/src-tauri/.gitignore
@@ -0,0 +1,7 @@
+# Generated by Cargo
+# will have compiled files and executables
+/target/
+
+# Generated by Tauri
+# will have schema files for capabilities auto-completion
+/gen/schemas
diff --git a/tools/sprite-studio/src-tauri/Cargo.lock b/tools/sprite-studio/src-tauri/Cargo.lock
new file mode 100644
index 0000000..d21620a
--- /dev/null
+++ b/tools/sprite-studio/src-tauri/Cargo.lock
@@ -0,0 +1,4019 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "atk"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3d816ce6f0e2909a96830d6911c2aff044370b1ef92d7f267b43bae5addedd"
+dependencies = [
+ "atk-sys",
+ "bitflags 1.3.2",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58aeb089fb698e06db8089971c7ee317ab9644bade33383f63631437b03aafb6"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "borrow-fighters-sprite-studio"
+version = "0.1.0"
+dependencies = [
+ "base64 0.22.1",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+]
+
+[[package]]
+name = "brotli"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "4.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bstr"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
+[[package]]
+name = "cairo-rs"
+version = "0.15.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c76ee391b03d35510d9fa917357c7f1855bd9a6659c95a1b392e33f49b3369bc"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "thiserror",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c55d429bef56ac9172d25fecb85dc8068307d17acd74b377866b7a1ef25d3c8"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838"
+dependencies = [
+ "serde",
+ "toml 0.7.8",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
+dependencies = [
+ "byteorder",
+ "fnv",
+ "uuid",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3431df59f28accaf4cb4eed4a9acc66bea3f3c3753aa6cdc2f024174ef232af7"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "num-traits",
+ "serde",
+ "windows-link",
+]
+
+[[package]]
+name = "cocoa"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "cocoa-foundation",
+ "core-foundation",
+ "core-graphics",
+ "foreign-types",
+ "libc",
+ "objc",
+]
+
+[[package]]
+name = "cocoa-foundation"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "core-foundation",
+ "core-graphics-types",
+ "libc",
+ "objc",
+]
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa 0.4.8",
+ "matches",
+ "phf 0.8.0",
+ "proc-macro2",
+ "quote",
+ "smallvec",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "ctor"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "0.99.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
+dependencies = [
+ "convert_case",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d506610004cfc74a6f5ee7e8c632b355de5eca1f03ee5e5e0ec11b77d4eb3d61"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 0.8.2",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fluent-uri"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "fxhash"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "gdk"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6e05c1f572ab0e1f15be94217f0dc29088c248b14f792a5ff0af0d84bcda9e8"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.15.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad38dd9cc8b099cceecdf41375bb6d481b1b5a7cd5cd603e10a69a9383f8619a"
+dependencies = [
+ "bitflags 1.3.2",
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140b2f5378256527150350a8346dbdb08fadc13453a7a2d73aecd5fab3c402a7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e7a08c1e8f06f4177fb7e51a777b8c1689f743a7bc11ea91d44d2226073a88"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cca49a59ad8cfdf36ef7330fe7bdfbe1d34323220cc16a0de2679ee773aee2c2"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4b7f8c7a84b407aa9b143877e267e848ff34106578b64d1e0a24bf550716178"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps 6.2.2",
+ "x11",
+]
+
+[[package]]
+name = "generator"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e"
+dependencies = [
+ "cc",
+ "libc",
+ "log",
+ "rustversion",
+ "windows 0.48.0",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "gio"
+version = "0.15.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68fdbc90312d462781a395f7a16d96a2b379bb6ef8cd6310a2df272771c4283b"
+dependencies = [
+ "bitflags 1.3.2",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32157a475271e2c4a023382e9cab31c4584ee30a97da41d3c4e9fdd605abcf8d"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.15.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edb0306fbad0ab5428b0ca674a23893db909a98582969c9b537be4ced78c505d"
+dependencies = [
+ "bitflags 1.3.2",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "once_cell",
+ "smallvec",
+ "thiserror",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.15.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10c6ae9f6fa26f4fb2ac16b528d138d971ead56141de489f8111e259b9df3c4a"
+dependencies = [
+ "anyhow",
+ "heck 0.4.1",
+ "proc-macro-crate",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef4b192f8e65e9cf76cbf4ea71fa8e3be4a0e18ffe3d68b8da6836974cc5bad4"
+dependencies = [
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
+[[package]]
+name = "globset"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "gobject-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d57ce44246becd17153bd035ab4d32cfee096a657fc01f2231c9278378d1e0a"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gtk"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e3004a2d5d6d8b5057d2b57b3712c9529b62e82c77f25c1fecde1fd5c23bd0"
+dependencies = [
+ "atk",
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "once_cell",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5bc2f0587cba247f60246a0ca11fe25fb733eabc3de12d1965fc07efab87c84"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.15.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "684c0456c086e8e7e9af73ec5b84e35938df394712054550e81558d21c44ab0d"
+dependencies = [
+ "anyhow",
+ "proc-macro-crate",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa 1.0.18",
+]
+
+[[package]]
+name = "http-range"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
+dependencies = [
+ "byteorder",
+ "png",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "ignore"
+version = "0.4.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
+[[package]]
+name = "image"
+version = "0.24.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+ "color_quant",
+ "num-traits",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f551f8c3a39f68f986517db0d1759de85881894fdc7db798bd2a9df9cb04b7fc"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "itoa"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf053e7843f2812ff03ef5afe34bb9c06ffee120385caad4f6b9967fcd37d41c"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "905fbb87419c5cde6e3269537e4ea7d46431f3008c5d057e915ef3f115e7793c"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 5.0.0",
+]
+
+[[package]]
+name = "jni"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c"
+dependencies = [
+ "cesu8",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror",
+ "walkdir",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c6e529149475ca0b2820835d3dce8fcc41c6b943ca608d32f35b449255e4627"
+dependencies = [
+ "fluent-uri",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "kuchikiki"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8"
+dependencies = [
+ "cssparser",
+ "html5ever",
+ "indexmap 1.9.3",
+ "matches",
+ "selectors",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "loom"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "serde",
+ "serde_json",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "markup5ever"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016"
+dependencies = [
+ "log",
+ "phf 0.10.1",
+ "phf_codegen 0.10.0",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matches"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "ndk"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2032c77e030ddee34a6787a64166008da93f6a352b629261d0fee232b8742dd4"
+dependencies = [
+ "bitflags 1.3.2",
+ "jni-sys 0.3.1",
+ "ndk-sys",
+ "num_enum",
+ "thiserror",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e5a6ae77c8ee183dcbbba6150e2e6b9f3f4196a7666c02a715a95692ec1fa97"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "nodrop"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+ "objc_exception",
+]
+
+[[package]]
+name = "objc-foundation"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
+dependencies = [
+ "block",
+ "objc",
+ "objc_id",
+]
+
+[[package]]
+name = "objc_exception"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "pango"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e4045548659aee5313bde6c582b0d83a627b7904dd20dc2d9ef0895d414e4f"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2a00081cde4661982ed91d80ef437c20eacaf6aa1a5962c0279ae194662c3aa"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
+dependencies = [
+ "phf_macros 0.8.0",
+ "phf_shared 0.8.0",
+ "proc-macro-hack",
+]
+
+[[package]]
+name = "phf"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
+dependencies = [
+ "phf_shared 0.10.0",
+]
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_macros 0.11.3",
+ "phf_shared 0.11.3",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815"
+dependencies = [
+ "phf_generator 0.8.0",
+ "phf_shared 0.8.0",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
+dependencies = [
+ "phf_generator 0.10.0",
+ "phf_shared 0.10.0",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526"
+dependencies = [
+ "phf_shared 0.8.0",
+ "rand 0.7.3",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
+dependencies = [
+ "phf_shared 0.10.0",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared 0.11.3",
+ "rand 0.8.6",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
+dependencies = [
+ "phf_generator 0.8.0",
+ "phf_shared 0.8.0",
+ "proc-macro-hack",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7"
+dependencies = [
+ "siphasher 0.3.11",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
+dependencies = [
+ "siphasher 0.3.11",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher 1.0.3",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plist"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
+dependencies = [
+ "base64 0.22.1",
+ "indexmap 2.14.0",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-hack"
+version = "0.5.20+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03"
+dependencies = [
+ "getrandom 0.1.16",
+ "libc",
+ "rand_chacha 0.2.2",
+ "rand_core 0.5.1",
+ "rand_hc",
+ "rand_pcg",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+dependencies = [
+ "getrandom 0.1.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_hc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "rand_pcg"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429"
+dependencies = [
+ "rand_core 0.5.1",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.0",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rfd"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0149778bd99b6959285b0933288206090c50e2327f47a9c463bfdbf45c8823ea"
+dependencies = [
+ "block",
+ "dispatch",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "js-sys",
+ "lazy_static",
+ "log",
+ "objc",
+ "objc-foundation",
+ "objc_id",
+ "raw-window-handle",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "windows 0.37.0",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.0",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "selectors"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
+dependencies = [
+ "bitflags 1.3.2",
+ "cssparser",
+ "derive_more",
+ "fxhash",
+ "log",
+ "matches",
+ "phf 0.8.0",
+ "phf_codegen 0.8.0",
+ "precomputed-hash",
+ "servo_arc",
+ "smallvec",
+ "thin-slice",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "indexmap 2.14.0",
+ "itoa 1.0.18",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.1",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432"
+dependencies = [
+ "nodrop",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "siphasher"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "soup2"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b4d76501d8ba387cf0fefbe055c3e0a59891d09f0f995ae4e4b16f6b60f3c0"
+dependencies = [
+ "bitflags 1.3.2",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "soup2-sys",
+]
+
+[[package]]
+name = "soup2-sys"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "009ef427103fcb17f802871647a7fa6c60cbb654b4c4e4c0ac60a31c5f6dc9cf"
+dependencies = [
+ "bitflags 1.3.2",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps 5.0.0",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "state"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b"
+dependencies = [
+ "loom",
+]
+
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared 0.11.3",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "system-deps"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18db855554db7bd0e73e06cf7ba3df39f97812cb11d3f75e71c39bf45171797e"
+dependencies = [
+ "cfg-expr 0.9.1",
+ "heck 0.3.3",
+ "pkg-config",
+ "toml 0.5.11",
+ "version-compare 0.0.11",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr 0.15.8",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare 0.2.1",
+]
+
+[[package]]
+name = "tao"
+version = "0.16.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf915e6c7112402f7b88a064cfbd264f851052df07fdc3a2abd3038b0cc434a"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "cc",
+ "cocoa",
+ "core-foundation",
+ "core-graphics",
+ "crossbeam-channel",
+ "dispatch",
+ "gdk",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "glib-sys",
+ "gtk",
+ "image",
+ "instant",
+ "jni",
+ "lazy_static",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "objc",
+ "once_cell",
+ "parking_lot",
+ "png",
+ "raw-window-handle",
+ "scopeguard",
+ "serde",
+ "tao-macros",
+ "unicode-segmentation",
+ "uuid",
+ "windows 0.39.0",
+ "windows-implement 0.39.0",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae1f57c291a6ab8e1d2e6b8ad0a35ff769c9925deb8a89de85425ff08762d0c"
+dependencies = [
+ "anyhow",
+ "cocoa",
+ "dirs-next",
+ "dunce",
+ "embed_plist",
+ "encoding_rs",
+ "flate2",
+ "futures-util",
+ "getrandom 0.2.17",
+ "glib",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "ignore",
+ "log",
+ "objc",
+ "once_cell",
+ "percent-encoding",
+ "plist",
+ "rand 0.8.6",
+ "raw-window-handle",
+ "rfd",
+ "semver",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "state",
+ "tar",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "url",
+ "uuid",
+ "webkit2gtk",
+ "webview2-com",
+ "windows 0.39.0",
+]
+
+[[package]]
+name = "tauri-build"
+version = "1.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2db08694eec06f53625cfc6fff3a363e084e5e9a238166d2989996413c346453"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs-next",
+ "heck 0.5.0",
+ "json-patch",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "1.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53438d78c4a037ffe5eafa19e447eea599bedfb10844cb08ec53c2471ac3ac3f"
+dependencies = [
+ "base64 0.21.7",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "tauri-utils",
+ "thiserror",
+ "time",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "1.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233988ac08c1ed3fe794cd65528d48d8f7ed4ab3895ca64cdaa6ad4d00c45c0b"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "0.14.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8066855882f00172935e3fa7d945126580c34dcbabab43f5d4f0c2398a67d47b"
+dependencies = [
+ "gtk",
+ "http",
+ "http-range",
+ "rand 0.8.6",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror",
+ "url",
+ "uuid",
+ "webview2-com",
+ "windows 0.39.0",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "0.14.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce361fec1e186705371f1c64ae9dd2a3a6768bc530d0a2d5e75a634bb416ad4d"
+dependencies = [
+ "cocoa",
+ "gtk",
+ "percent-encoding",
+ "rand 0.8.6",
+ "raw-window-handle",
+ "tauri-runtime",
+ "tauri-utils",
+ "uuid",
+ "webkit2gtk",
+ "webview2-com",
+ "windows 0.39.0",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c357952645e679de02cd35007190fcbce869b93ffc61b029f33fe02648453774"
+dependencies = [
+ "brotli",
+ "ctor",
+ "dunce",
+ "glob",
+ "heck 0.5.0",
+ "html5ever",
+ "infer",
+ "json-patch",
+ "kuchikiki",
+ "log",
+ "memchr",
+ "phf 0.11.3",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "serde_with",
+ "thiserror",
+ "url",
+ "walkdir",
+ "windows-version",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb"
+dependencies = [
+ "embed-resource",
+ "toml 0.7.8",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
+[[package]]
+name = "thin-slice"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.14.0",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version-compare"
+version = "0.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c18c859eead79d8b95d09e4678566e8d70105c4e7b251f707a03df32442661b"
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.9.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8f859735e4a452aeb28c6c56a852967a8a76c8eb1cc32dbf931ad28a13d6370"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup2",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d76ca6ecc47aeba01ec61e480139dda143796abcae6f83bcddf50d6b5b1dcf3"
+dependencies = [
+ "atk-sys",
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "soup2-sys",
+ "system-deps 6.2.2",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4a769c9f1a64a8734bde70caafac2b96cada12cd4aefa49196b3a386b8b4178"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows 0.39.0",
+ "windows-implement 0.39.0",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eaebe196c01691db62e9e4ca52c5ef1e4fd837dcae27dae3ada599b5a8fd05ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac48ef20ddf657755fdcda8dfed2a7b4fc7e4581acce6fe9b88c3d64f29dee7"
+dependencies = [
+ "regex",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "windows 0.39.0",
+ "windows-bindgen",
+ "windows-metadata",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57b543186b344cc61c85b5aab0d2e3adf4e0f99bc076eff9aa5927bcc0b8a647"
+dependencies = [
+ "windows_aarch64_msvc 0.37.0",
+ "windows_i686_gnu 0.37.0",
+ "windows_i686_msvc 0.37.0",
+ "windows_x86_64_gnu 0.37.0",
+ "windows_x86_64_msvc 0.37.0",
+]
+
+[[package]]
+name = "windows"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1c4bd0a50ac6020f65184721f758dba47bb9fbc2133df715ec74a237b26794a"
+dependencies = [
+ "windows-implement 0.39.0",
+ "windows_aarch64_msvc 0.39.0",
+ "windows_i686_gnu 0.39.0",
+ "windows_i686_msvc 0.39.0",
+ "windows_x86_64_gnu 0.39.0",
+ "windows_x86_64_msvc 0.39.0",
+]
+
+[[package]]
+name = "windows"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-bindgen"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68003dbd0e38abc0fb85b939240f4bce37c43a5981d3df37ccbaaa981b47cb41"
+dependencies = [
+ "windows-metadata",
+ "windows-tokens",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba01f98f509cb5dc05f4e5fc95e535f78260f15fea8fe1a8abdd08f774f1cee7"
+dependencies = [
+ "syn 1.0.109",
+ "windows-tokens",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-metadata"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ee5e275231f07c6e240d14f34e1b635bf1faa1c76c57cfd59a5cdb9848e4278"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-tokens"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f838de2fe15fe6bac988e74b798f26499a8b21a9d97edec321e79b28d1d7f597"
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2623277cb2d1c216ba3b578c0f3cf9cdebeddb6e66b1b218bb33596ea7769c3a"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec7711666096bd4096ffa835238905bb33fb87267910e154b18b44eaabb340f2"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3925fd0b0b804730d44d4b6278c50f9699703ec49bcd628020f46f4ba07d9e1"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "763fc57100a5f7042e3057e7e8d9bdd7860d330070251a73d003563a3bb49e1b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce907ac74fe331b524c1298683efbf598bb031bc84d5e274db2083696d07c57c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bc7cbfe58828921e10a9f446fcaaf649204dcfe6c1ddd712c5eebae6bda1106"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2babfba0828f2e6b32457d5341427dcbb577ceef556273229959ac23a10af33d"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6868c165637d653ae1e8dc4d82c25d4f97dd6605eaa8d784b5c6e0ab2a252b65"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4dd6dc7df2d84cf7b33822ed5b86318fb1781948e9663bacd047fc9dd52259d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e4d40883ae9cae962787ca76ba76390ffa29214667a111db9e0a1ad8377e809"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "wry"
+version = "0.24.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a2a144c3ab5e83e04724bc8e67cea552ffae413185fda459fafdae173fd985d"
+dependencies = [
+ "base64 0.13.1",
+ "block",
+ "cocoa",
+ "core-graphics",
+ "crossbeam-channel",
+ "dunce",
+ "gdk",
+ "gio",
+ "glib",
+ "gtk",
+ "html5ever",
+ "http",
+ "kuchikiki",
+ "libc",
+ "log",
+ "objc",
+ "objc_id",
+ "once_cell",
+ "serde",
+ "serde_json",
+ "sha2",
+ "soup2",
+ "tao",
+ "thiserror",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows 0.39.0",
+ "windows-implement 0.39.0",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/tools/sprite-studio/src-tauri/Cargo.toml b/tools/sprite-studio/src-tauri/Cargo.toml
new file mode 100644
index 0000000..0e4c3de
--- /dev/null
+++ b/tools/sprite-studio/src-tauri/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "borrow-fighters-sprite-studio"
+version = "0.1.0"
+description = "Borrow Fighters sprite atlas and combat metadata tool"
+authors = ["Borrow Fighters contributors"]
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[lib]
+# The `_lib` suffix may seem redundant but it is necessary
+# to make the lib name unique and wouldn't conflict with the bin name.
+# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
+name = "borrow_fighters_sprite_studio_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[features]
+custom-protocol = ["tauri/custom-protocol"]
+
+[build-dependencies]
+tauri-build = { version = "=1.5.6", features = [] }
+
+[dependencies]
+tauri = { version = "=1.8.3", features = [
+ "custom-protocol",
+ "dialog-open",
+ "dialog-save",
+] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+base64 = "0.22"
diff --git a/tools/sprite-studio/src-tauri/build.rs b/tools/sprite-studio/src-tauri/build.rs
new file mode 100644
index 0000000..d860e1e
--- /dev/null
+++ b/tools/sprite-studio/src-tauri/build.rs
@@ -0,0 +1,3 @@
+fn main() {
+ tauri_build::build()
+}
diff --git a/tools/sprite-studio/src-tauri/icons/128x128.png b/tools/sprite-studio/src-tauri/icons/128x128.png
new file mode 100644
index 0000000..6be5e50
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/128x128.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/128x128@2x.png b/tools/sprite-studio/src-tauri/icons/128x128@2x.png
new file mode 100644
index 0000000..e81bece
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/128x128@2x.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/32x32.png b/tools/sprite-studio/src-tauri/icons/32x32.png
new file mode 100644
index 0000000..a437dd5
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/32x32.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square107x107Logo.png b/tools/sprite-studio/src-tauri/icons/Square107x107Logo.png
new file mode 100644
index 0000000..0ca4f27
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square107x107Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square142x142Logo.png b/tools/sprite-studio/src-tauri/icons/Square142x142Logo.png
new file mode 100644
index 0000000..b81f820
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square142x142Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square150x150Logo.png b/tools/sprite-studio/src-tauri/icons/Square150x150Logo.png
new file mode 100644
index 0000000..624c7bf
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square150x150Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square284x284Logo.png b/tools/sprite-studio/src-tauri/icons/Square284x284Logo.png
new file mode 100644
index 0000000..c021d2b
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square284x284Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square30x30Logo.png b/tools/sprite-studio/src-tauri/icons/Square30x30Logo.png
new file mode 100644
index 0000000..6219700
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square30x30Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square310x310Logo.png b/tools/sprite-studio/src-tauri/icons/Square310x310Logo.png
new file mode 100644
index 0000000..f9bc048
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square310x310Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square44x44Logo.png b/tools/sprite-studio/src-tauri/icons/Square44x44Logo.png
new file mode 100644
index 0000000..d5fbfb2
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square44x44Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square71x71Logo.png b/tools/sprite-studio/src-tauri/icons/Square71x71Logo.png
new file mode 100644
index 0000000..63440d7
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square71x71Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/Square89x89Logo.png b/tools/sprite-studio/src-tauri/icons/Square89x89Logo.png
new file mode 100644
index 0000000..f3f705a
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/Square89x89Logo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/StoreLogo.png b/tools/sprite-studio/src-tauri/icons/StoreLogo.png
new file mode 100644
index 0000000..4556388
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/StoreLogo.png differ
diff --git a/tools/sprite-studio/src-tauri/icons/icon.icns b/tools/sprite-studio/src-tauri/icons/icon.icns
new file mode 100644
index 0000000..12a5bce
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/icon.icns differ
diff --git a/tools/sprite-studio/src-tauri/icons/icon.ico b/tools/sprite-studio/src-tauri/icons/icon.ico
new file mode 100644
index 0000000..b3636e4
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/icon.ico differ
diff --git a/tools/sprite-studio/src-tauri/icons/icon.png b/tools/sprite-studio/src-tauri/icons/icon.png
new file mode 100644
index 0000000..e1cd261
Binary files /dev/null and b/tools/sprite-studio/src-tauri/icons/icon.png differ
diff --git a/tools/sprite-studio/src-tauri/src/lib.rs b/tools/sprite-studio/src-tauri/src/lib.rs
new file mode 100644
index 0000000..c183792
--- /dev/null
+++ b/tools/sprite-studio/src-tauri/src/lib.rs
@@ -0,0 +1,354 @@
+//! Provides local file commands for the Borrow Fighters Sprite Studio.
+//!
+//! System: Sprite Studio tooling. This backend is intentionally isolated from
+//! the game crate and only reads or writes artifact files on disk.
+
+use std::{
+ env, fs, io,
+ path::{Path, PathBuf},
+ process::Command,
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use base64::{engine::general_purpose, Engine as _};
+use serde::Serialize;
+use serde_json::Value;
+
+#[derive(Debug, Serialize)]
+struct SpriteDocument {
+ repo_root: String,
+ manifest_path: String,
+ atlas_path: String,
+ manifest_json: String,
+ atlas_data_url: String,
+}
+
+#[derive(Debug, Serialize)]
+struct SaveResult {
+ path: String,
+ bytes_written: usize,
+ backup_path: Option,
+}
+
+#[derive(Debug, Serialize)]
+struct TextDocument {
+ path: String,
+ contents: String,
+}
+
+#[derive(Debug, Serialize)]
+struct RuntimeValidationResult {
+ success: bool,
+ command: String,
+ stdout: String,
+ stderr: String,
+}
+
+#[derive(Debug, Serialize)]
+struct StudioError {
+ message: String,
+}
+
+impl From for StudioError {
+ fn from(error: io::Error) -> Self {
+ Self {
+ message: error.to_string(),
+ }
+ }
+}
+
+impl From for StudioError {
+ fn from(error: serde_json::Error) -> Self {
+ Self {
+ message: error.to_string(),
+ }
+ }
+}
+
+fn repo_root() -> Result {
+ let mut current = env::current_dir()?;
+ loop {
+ if current.join("Cargo.toml").is_file()
+ && current.join("assets").is_dir()
+ && current.join("docs").is_dir()
+ {
+ return Ok(current);
+ }
+ if !current.pop() {
+ return Err(StudioError {
+ message: "Could not find Borrow Fighters repository root".to_string(),
+ });
+ }
+ }
+}
+
+fn normalize_repo_path(path: &str) -> Result {
+ let root = repo_root()?;
+ let raw = Path::new(path);
+ let full = if raw.is_absolute() {
+ raw.to_path_buf()
+ } else {
+ root.join(raw)
+ };
+ let normalized = full.canonicalize().map_err(|error| StudioError {
+ message: format!("Could not resolve path `{path}`: {error}"),
+ })?;
+ if !normalized.starts_with(&root) {
+ return Err(StudioError {
+ message: format!("Path `{path}` is outside the repository"),
+ });
+ }
+ Ok(normalized)
+}
+
+fn normalize_repo_write_path(path: &str) -> Result {
+ let root = repo_root()?;
+ let raw = Path::new(path);
+ let full = if raw.is_absolute() {
+ raw.to_path_buf()
+ } else {
+ root.join(raw)
+ };
+ let parent = full
+ .parent()
+ .ok_or_else(|| StudioError {
+ message: format!("Path `{path}` has no parent directory"),
+ })?
+ .canonicalize()
+ .map_err(|error| StudioError {
+ message: format!("Could not resolve parent for `{path}`: {error}"),
+ })?;
+ let file_name = full.file_name().ok_or_else(|| StudioError {
+ message: format!("Path `{path}` has no file name"),
+ })?;
+ let normalized = parent.join(file_name);
+ if !normalized.starts_with(&root) {
+ return Err(StudioError {
+ message: format!("Path `{path}` is outside the repository"),
+ });
+ }
+ Ok(normalized)
+}
+
+fn display_repo_path(path: &Path) -> Result {
+ let root = repo_root()?;
+ let relative = path.strip_prefix(&root).unwrap_or(path);
+ Ok(relative.to_string_lossy().replace('\\', "/"))
+}
+
+fn image_data_url(path: &Path) -> Result {
+ let bytes = fs::read(path)?;
+ let mime = match path.extension().and_then(|extension| extension.to_str()) {
+ Some("jpg") | Some("jpeg") => "image/jpeg",
+ Some("webp") => "image/webp",
+ _ => "image/png",
+ };
+ Ok(format!(
+ "data:{mime};base64,{}",
+ general_purpose::STANDARD.encode(bytes)
+ ))
+}
+
+fn safe_artifact_name(path: &str) -> String {
+ path.chars()
+ .map(|character| {
+ if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
+ character
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+fn unix_timestamp() -> Result {
+ Ok(SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|error| StudioError {
+ message: format!("System clock is before Unix epoch: {error}"),
+ })?
+ .as_secs())
+}
+
+fn tool_output_dir(name: &str) -> Result {
+ let dir = repo_root()?.join("target").join(name);
+ fs::create_dir_all(&dir)?;
+ Ok(dir)
+}
+
+fn backup_existing_file(path: &Path) -> Result, StudioError> {
+ if !path.is_file() {
+ return Ok(None);
+ }
+ let display = display_repo_path(path)?;
+ let backup_name = format!(
+ "{}.{}.bak.json",
+ safe_artifact_name(&display),
+ unix_timestamp()?
+ );
+ let backup_path = tool_output_dir("sprite-studio-backups")?.join(backup_name);
+ fs::copy(path, &backup_path)?;
+ Ok(Some(backup_path))
+}
+
+#[tauri::command]
+fn load_sprite_manifest(path: String) -> Result {
+ let manifest_path = normalize_repo_path(&path)?;
+ let manifest_json = fs::read_to_string(&manifest_path)?;
+ let manifest: Value = serde_json::from_str(&manifest_json)?;
+ let image_name = manifest
+ .get("image")
+ .and_then(Value::as_str)
+ .ok_or_else(|| StudioError {
+ message: "Manifest is missing string field `image`".to_string(),
+ })?;
+ let atlas_path = manifest_path
+ .parent()
+ .ok_or_else(|| StudioError {
+ message: "Manifest path has no parent directory".to_string(),
+ })?
+ .join(image_name)
+ .canonicalize()
+ .map_err(|error| StudioError {
+ message: format!("Could not resolve atlas `{image_name}`: {error}"),
+ })?;
+ let root = repo_root()?;
+ if !atlas_path.starts_with(&root) {
+ return Err(StudioError {
+ message: format!("Atlas `{image_name}` is outside the repository"),
+ });
+ }
+
+ Ok(SpriteDocument {
+ repo_root: root.to_string_lossy().to_string(),
+ manifest_path: display_repo_path(&manifest_path)?,
+ atlas_path: display_repo_path(&atlas_path)?,
+ manifest_json,
+ atlas_data_url: image_data_url(&atlas_path)?,
+ })
+}
+
+#[tauri::command]
+fn save_sprite_manifest(path: String, manifest_json: String) -> Result {
+ let manifest_path = normalize_repo_write_path(&path)?;
+ let parsed: Value = serde_json::from_str(&manifest_json)?;
+ let pretty = format!("{}\n", serde_json::to_string_pretty(&parsed)?);
+ let backup_path = backup_existing_file(&manifest_path)?;
+ fs::write(&manifest_path, pretty.as_bytes())?;
+
+ Ok(SaveResult {
+ path: display_repo_path(&manifest_path)?,
+ bytes_written: pretty.len(),
+ backup_path: backup_path.as_deref().map(display_repo_path).transpose()?,
+ })
+}
+
+#[tauri::command]
+fn autosave_sprite_manifest(
+ path: String,
+ manifest_json: String,
+) -> Result {
+ let parsed: Value = serde_json::from_str(&manifest_json)?;
+ let pretty = format!("{}\n", serde_json::to_string_pretty(&parsed)?);
+ let target_name = format!("{}.autosave.json", safe_artifact_name(&path));
+ let autosave_path = tool_output_dir("sprite-studio-autosave")?.join(target_name);
+ fs::write(&autosave_path, pretty.as_bytes())?;
+
+ Ok(SaveResult {
+ path: display_repo_path(&autosave_path)?,
+ bytes_written: pretty.len(),
+ backup_path: None,
+ })
+}
+
+#[tauri::command]
+fn load_repo_text_file(path: String) -> Result {
+ let file_path = normalize_repo_path(&path)?;
+ Ok(TextDocument {
+ path: display_repo_path(&file_path)?,
+ contents: fs::read_to_string(&file_path)?,
+ })
+}
+
+#[tauri::command]
+fn save_repo_text_file(path: String, contents: String) -> Result {
+ let file_path = normalize_repo_write_path(&path)?;
+ fs::write(&file_path, contents.as_bytes())?;
+ Ok(SaveResult {
+ path: display_repo_path(&file_path)?,
+ bytes_written: contents.len(),
+ backup_path: None,
+ })
+}
+
+#[tauri::command]
+fn studio_repo_root() -> Result {
+ Ok(repo_root()?.to_string_lossy().to_string())
+}
+
+#[tauri::command]
+fn validate_runtime_assets() -> Result {
+ let root = repo_root()?;
+ let args = [
+ "test",
+ "--test",
+ "sprite_manifest",
+ "--test",
+ "sprite_frames",
+ "--test",
+ "sprite_selection",
+ "--test",
+ "characters",
+ ];
+ let output = Command::new("cargo")
+ .args(args)
+ .current_dir(&root)
+ .output()
+ .map_err(|error| StudioError {
+ message: format!("Could not run cargo validation: {error}"),
+ })?;
+
+ Ok(RuntimeValidationResult {
+ success: output.status.success(),
+ command: format!("cargo {}", args.join(" ")),
+ stdout: String::from_utf8_lossy(&output.stdout).to_string(),
+ stderr: String::from_utf8_lossy(&output.stderr).to_string(),
+ })
+}
+
+#[cfg_attr(mobile, tauri::mobile_entry_point)]
+pub fn run() {
+ tauri::Builder::default()
+ .invoke_handler(tauri::generate_handler![
+ load_sprite_manifest,
+ load_repo_text_file,
+ save_repo_text_file,
+ save_sprite_manifest,
+ autosave_sprite_manifest,
+ studio_repo_root,
+ validate_runtime_assets
+ ])
+ .run(tauri::generate_context!())
+ .expect("error while running Borrow Fighters Sprite Studio");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn write_path_allows_new_files_inside_repo() {
+ let path = normalize_repo_write_path("target/sprite-studio-review-test.json")
+ .expect("new file under repo target should be writable");
+
+ assert!(path.ends_with("target/sprite-studio-review-test.json"));
+ }
+
+ #[test]
+ fn write_path_rejects_files_outside_repo() {
+ let error = normalize_repo_write_path("/tmp/sprite-studio-review-test.json")
+ .expect_err("absolute path outside repo should be rejected");
+
+ assert!(error.message.contains("outside the repository"));
+ }
+}
diff --git a/tools/sprite-studio/src-tauri/src/main.rs b/tools/sprite-studio/src-tauri/src/main.rs
new file mode 100644
index 0000000..26a5d2c
--- /dev/null
+++ b/tools/sprite-studio/src-tauri/src/main.rs
@@ -0,0 +1,6 @@
+// Prevents additional console window on Windows in release, DO NOT REMOVE!!
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+fn main() {
+ borrow_fighters_sprite_studio_lib::run()
+}
diff --git a/tools/sprite-studio/src-tauri/tauri.conf.json b/tools/sprite-studio/src-tauri/tauri.conf.json
new file mode 100644
index 0000000..cf20fbe
--- /dev/null
+++ b/tools/sprite-studio/src-tauri/tauri.conf.json
@@ -0,0 +1,46 @@
+{
+ "$schema": "../node_modules/@tauri-apps/cli/schema.json",
+ "build": {
+ "beforeDevCommand": "pnpm dev",
+ "beforeBuildCommand": "pnpm build",
+ "devPath": "http://localhost:1420",
+ "distDir": "../dist"
+ },
+ "package": {
+ "productName": "Borrow Fighters Sprite Studio",
+ "version": "0.1.0"
+ },
+ "tauri": {
+ "allowlist": {
+ "all": false,
+ "dialog": {
+ "open": true,
+ "save": true
+ }
+ },
+ "windows": [
+ {
+ "title": "Borrow Fighters Sprite Studio",
+ "width": 1280,
+ "height": 820,
+ "minWidth": 1080,
+ "minHeight": 720
+ }
+ ],
+ "security": {
+ "csp": null
+ },
+ "bundle": {
+ "active": true,
+ "targets": "all",
+ "identifier": "dev.borrowfighters.spritestudio",
+ "icon": [
+ "icons/32x32.png",
+ "icons/128x128.png",
+ "icons/128x128@2x.png",
+ "icons/icon.icns",
+ "icons/icon.ico"
+ ]
+ }
+ }
+}
diff --git a/tools/sprite-studio/src/App.css b/tools/sprite-studio/src/App.css
new file mode 100644
index 0000000..805ac62
--- /dev/null
+++ b/tools/sprite-studio/src/App.css
@@ -0,0 +1,1403 @@
+:root {
+ color: #e8edf2;
+ background: #12161b;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+ font-size: 14px;
+ font-synthesis: none;
+ line-height: 1.35;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+ margin: 0;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button {
+ border: 1px solid #3a4653;
+ border-radius: 6px;
+ background: #202832;
+ color: #e8edf2;
+ cursor: pointer;
+ min-height: 32px;
+ padding: 6px 10px;
+}
+
+button:hover:not(:disabled) {
+ border-color: #6aa7d8;
+ background: #253344;
+}
+
+button:disabled {
+ color: #6d7885;
+ cursor: not-allowed;
+}
+
+input,
+select,
+textarea {
+ border: 1px solid #33404c;
+ border-radius: 6px;
+ background: #0e1216;
+ color: #e8edf2;
+ min-height: 32px;
+ padding: 6px 8px;
+ width: 100%;
+}
+
+textarea {
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.45;
+ resize: none;
+}
+
+label {
+ color: #aeb9c5;
+ display: grid;
+ gap: 5px;
+}
+
+h1,
+h2,
+p,
+dl,
+dd {
+ margin: 0;
+}
+
+h1 {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+h2 {
+ color: #f4f7fa;
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.studio-shell {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ height: 100%;
+ min-width: 1080px;
+}
+
+.app-chrome {
+ background: #151a20;
+ border-bottom: 1px solid #28323c;
+ display: grid;
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.menu-bar {
+ align-items: center;
+ background: #0f141a;
+ border-bottom: 1px solid #232d37;
+ display: flex;
+ gap: 3px;
+ min-height: 32px;
+ padding: 0 10px 0 12px;
+}
+
+.menu-logo {
+ border: 1px solid #3a4653;
+ border-radius: 5px;
+ display: grid;
+ flex: 0 0 auto;
+ height: 20px;
+ margin-right: 8px;
+ place-items: center;
+ position: relative;
+ width: 24px;
+}
+
+.logo-mark-cut,
+.logo-mark-core,
+.logo-mark-spark {
+ position: absolute;
+}
+
+.logo-mark-cut {
+ background: #0f141a;
+ border: 1px solid #67d4ff;
+ border-radius: 2px;
+ height: 12px;
+ left: 4px;
+ transform: skewX(-12deg);
+ width: 9px;
+}
+
+.logo-mark-core {
+ background: linear-gradient(135deg, #ffb24a, #f46a62);
+ border-radius: 2px;
+ height: 10px;
+ right: 5px;
+ transform: skewX(-12deg);
+ width: 8px;
+}
+
+.logo-mark-spark {
+ background: #66ff99;
+ border-radius: 999px;
+ height: 4px;
+ right: 3px;
+ top: 3px;
+ width: 4px;
+}
+
+.menu-group {
+ position: relative;
+}
+
+.menu-trigger {
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: #d4dce4;
+ cursor: pointer;
+ min-height: 26px;
+ padding: 5px 10px;
+}
+
+.menu-group.open .menu-trigger,
+.menu-trigger:hover {
+ background: #202832;
+ border-color: #34414e;
+}
+
+.menu-dropdown {
+ background: #1b222b;
+ border: 1px solid #34414e;
+ border-radius: 6px;
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.42);
+ display: grid;
+ gap: 2px;
+ left: 0;
+ min-width: 230px;
+ padding: 6px;
+ position: absolute;
+ top: 30px;
+ z-index: 30;
+}
+
+.menu-dropdown button {
+ align-items: center;
+ background: transparent;
+ border-color: transparent;
+ display: flex;
+ justify-content: space-between;
+ min-height: 28px;
+ text-align: left;
+}
+
+.menu-dropdown button:hover:not(:disabled) {
+ background: #253344;
+ border-color: transparent;
+}
+
+kbd {
+ color: #8190a0;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 11px;
+ margin-left: 16px;
+}
+
+.toolbar {
+ align-items: center;
+ background: linear-gradient(#1c242d, #151b22);
+ display: flex;
+ gap: 8px;
+ justify-content: space-between;
+ min-height: 46px;
+ padding: 7px 12px;
+}
+
+.toolbar-group {
+ align-items: center;
+ display: flex;
+ gap: 5px;
+}
+
+.toolbar-spacer {
+ flex: 1;
+}
+
+.toolbar-divider {
+ background: #33404c;
+ height: 26px;
+ width: 1px;
+}
+
+.toolbar-path {
+ background: #10151b;
+ border: 1px solid #2b3642;
+ border-radius: 6px;
+ color: #90a0af;
+ font-size: 12px;
+ max-width: min(420px, 34vw);
+ min-width: 140px;
+ overflow: hidden;
+ padding: 6px 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.toolbar-button {
+ align-items: center;
+ background: #242d38;
+ border-color: #3b4856;
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.06),
+ 0 1px 0 rgba(0, 0, 0, 0.24);
+ display: inline-flex;
+ gap: 7px;
+ min-height: 30px;
+ padding: 5px 10px;
+}
+
+.toolbar-button:hover:not(:disabled) {
+ background: #2b3847;
+ border-color: #6aa7d8;
+}
+
+.toolbar-button.primary-action {
+ background: #173348;
+ border-color: #65c7ff;
+}
+
+.toolbar-button.toggle.active {
+ background: #263548;
+ border-color: #65c7ff;
+ color: #ffffff;
+}
+
+.toolbar-icon {
+ display: inline-block;
+ flex: 0 0 auto;
+ height: 16px;
+ position: relative;
+ width: 16px;
+}
+
+.open-icon::before,
+.open-icon::after,
+.save-icon::before,
+.save-icon::after,
+.browser-icon::before,
+.inspector-icon::before,
+.timeline-icon::before,
+.timeline-icon::after {
+ content: "";
+ position: absolute;
+}
+
+.open-icon::before {
+ border: 2px solid #ffd875;
+ border-radius: 2px;
+ height: 8px;
+ left: 2px;
+ top: 5px;
+ width: 12px;
+}
+
+.open-icon::after {
+ background: #ffd875;
+ border-radius: 2px 2px 0 0;
+ height: 4px;
+ left: 3px;
+ top: 2px;
+ width: 7px;
+}
+
+.save-icon::before {
+ border: 2px solid #8dc7ff;
+ border-radius: 2px;
+ inset: 2px;
+}
+
+.save-icon::after {
+ background: #8dc7ff;
+ border-radius: 1px;
+ bottom: 4px;
+ height: 3px;
+ left: 5px;
+ width: 6px;
+}
+
+.undo-icon::before,
+.redo-icon::before {
+ color: #aeb9c5;
+ font-size: 17px;
+ left: 0;
+ line-height: 16px;
+ position: absolute;
+ top: -1px;
+}
+
+.undo-icon::before {
+ content: "↶";
+}
+
+.redo-icon::before {
+ content: "↷";
+}
+
+.browser-icon::before,
+.inspector-icon::before {
+ border: 2px solid #aeb9c5;
+ border-radius: 2px;
+ inset: 2px;
+}
+
+.browser-icon::after,
+.inspector-icon::after {
+ background: #aeb9c5;
+ content: "";
+ position: absolute;
+ top: 4px;
+}
+
+.browser-icon::after {
+ bottom: 4px;
+ left: 5px;
+ width: 2px;
+}
+
+.inspector-icon::after {
+ bottom: 4px;
+ right: 5px;
+ width: 2px;
+}
+
+.timeline-icon::before {
+ background: repeating-linear-gradient(
+ 90deg,
+ #aeb9c5 0 7px,
+ transparent 7px 9px
+ );
+ height: 5px;
+ left: 1px;
+ top: 5px;
+ width: 14px;
+}
+
+.timeline-icon::after {
+ background: #65c7ff;
+ height: 12px;
+ left: 7px;
+ top: 2px;
+ width: 2px;
+}
+
+.help-icon::before {
+ color: #aeb9c5;
+ content: "?";
+ font-weight: 700;
+ left: 4px;
+ position: absolute;
+ top: -1px;
+}
+
+.workspace {
+ display: grid;
+ gap: 0;
+ grid-template-columns: 290px minmax(420px, 1fr) 340px;
+ min-height: 0;
+}
+
+.workspace.left-collapsed {
+ grid-template-columns: minmax(420px, 1fr) 340px;
+}
+
+.workspace.right-collapsed {
+ grid-template-columns: 290px minmax(420px, 1fr);
+}
+
+.workspace.left-collapsed.right-collapsed {
+ grid-template-columns: minmax(420px, 1fr);
+}
+
+.sidebar,
+.inspector {
+ background: #151a20;
+ display: grid;
+ gap: 12px;
+ overflow: auto;
+ padding: 12px;
+}
+
+.sidebar {
+ border-right: 1px solid #28323c;
+}
+
+.inspector {
+ border-left: 1px solid #28323c;
+}
+
+.stage-column {
+ background:
+ linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), #0f1318;
+ background-size: 32px 32px;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
+ min-width: 0;
+}
+
+.panel {
+ background: #1b222b;
+ border: 1px solid #2b3642;
+ border-radius: 8px;
+ display: grid;
+ gap: 12px;
+ padding: 12px;
+}
+
+.preset-grid,
+.property-grid,
+.quad-input {
+ display: grid;
+ gap: 8px;
+}
+
+.preset-grid {
+ grid-template-columns: 1fr 1fr;
+}
+
+.property-grid,
+.quad-input {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.metadata {
+ display: grid;
+ gap: 4px;
+}
+
+.metadata dt {
+ color: #8190a0;
+ font-size: 12px;
+}
+
+.metadata dd {
+ color: #d4dce4;
+ overflow-wrap: anywhere;
+}
+
+.clip-list,
+.frame-list {
+ display: grid;
+ gap: 6px;
+ max-height: 260px;
+ overflow: auto;
+}
+
+.clip-list button,
+.frame-list button {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ text-align: left;
+}
+
+.clip-list small {
+ color: #90a0af;
+}
+
+.selected {
+ border-color: #65c7ff;
+ background: #173348;
+ color: #ffffff;
+}
+
+.section-meta {
+ border-bottom: 1px solid #34414e;
+ color: #90a0af;
+ font-size: 12px;
+ padding-bottom: 7px;
+}
+
+.toolstrip {
+ align-items: center;
+ background: rgba(18, 22, 27, 0.92);
+ border-bottom: 1px solid #28323c;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ justify-content: flex-start;
+ min-height: 54px;
+ padding: 10px 14px;
+}
+
+.segmented {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.view-toggles {
+ align-items: center;
+ display: flex;
+ flex: 1 1 360px;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.view-toggles label {
+ align-items: center;
+ display: flex;
+ gap: 6px;
+}
+
+.view-toggles input {
+ min-height: auto;
+ width: auto;
+}
+
+.view-toggles select {
+ min-height: 28px;
+ width: 72px;
+}
+
+.snap-step {
+ grid-template-columns: auto 72px;
+}
+
+.inline-field {
+ align-items: center;
+ display: grid;
+ flex: 0 1 320px;
+ gap: 8px;
+ grid-template-columns: auto 180px 52px;
+ margin-left: auto;
+}
+
+.stage-scroll {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ min-height: 0;
+ overflow: auto;
+ padding: 28px;
+}
+
+.frame-stage {
+ background:
+ linear-gradient(45deg, #1a2027 25%, transparent 25%),
+ linear-gradient(-45deg, #1a2027 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #1a2027 75%),
+ linear-gradient(-45deg, transparent 75%, #1a2027 75%), #10151b;
+ background-position:
+ 0 0,
+ 0 8px,
+ 8px -8px,
+ -8px 0;
+ background-size: 16px 16px;
+ border: 1px solid #44515e;
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.4);
+ position: relative;
+ touch-action: none;
+}
+
+.frame-stage canvas,
+.frame-overlay {
+ display: block;
+ inset: 0;
+ position: absolute;
+}
+
+.frame-overlay {
+ pointer-events: none;
+}
+
+.grid-fill {
+ fill: url("#grid");
+ opacity: 0.35;
+}
+
+#grid path {
+ fill: none;
+ stroke: #7a8794;
+ stroke-width: 0.35;
+}
+
+.overlay-rect rect {
+ fill-opacity: 0.18;
+ stroke-width: 2;
+}
+
+.overlay-rect.selected-box rect {
+ stroke-width: 3;
+}
+
+.overlay-rect text {
+ font-size: 10px;
+ paint-order: stroke;
+ stroke: #10151b;
+ stroke-width: 3px;
+}
+
+.overlay-rect.hurt rect {
+ fill: #55d17c;
+ stroke: #66ff99;
+}
+
+.overlay-rect.hurt text {
+ fill: #bfffd1;
+}
+
+.overlay-rect.hit rect {
+ fill: #f46a62;
+ stroke: #ff8b83;
+}
+
+.overlay-rect.hit text {
+ fill: #ffd0cc;
+}
+
+.overlay-rect.trimmed rect {
+ fill: #51b9ff;
+ stroke: #6cc7ff;
+ stroke-dasharray: 6 4;
+}
+
+.overlay-rect.source rect {
+ fill: #f9c74f;
+ stroke: #ffd875;
+ stroke-dasharray: 3 3;
+}
+
+.pivot-line {
+ stroke: #f9c74f;
+ stroke-width: 1.2;
+}
+
+.pivot-point {
+ fill: #f9c74f;
+ stroke: #171d24;
+ stroke-width: 1.5;
+}
+
+.selected-point {
+ filter: drop-shadow(0 0 5px rgba(249, 199, 79, 0.95));
+}
+
+.projectile-origin line,
+.projectile-origin circle {
+ fill: #a78bfa;
+ stroke: #d7c7ff;
+ stroke-width: 2;
+}
+
+.resize-handle {
+ fill: #ffffff;
+ stroke: #111820;
+ stroke-width: 1.2;
+}
+
+.timeline-panel {
+ background: linear-gradient(#171d24, #12171d);
+ border-top: 1px solid #28323c;
+ display: grid;
+ gap: 6px;
+ max-height: 174px;
+ min-height: 136px;
+ overflow: hidden;
+ padding: 9px 12px 11px;
+}
+
+.timeline-header {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.timeline-summary {
+ display: grid;
+ gap: 2px;
+}
+
+.timeline-header strong {
+ color: #f4f7fa;
+}
+
+.timeline-header span {
+ color: #90a0af;
+ font-size: 12px;
+}
+
+.timeline-transport {
+ align-items: center;
+ background: #10151b;
+ border: 1px solid #34414e;
+ border-radius: 6px;
+ display: flex;
+ gap: 4px;
+ padding: 3px;
+}
+
+.transport-button {
+ border-color: transparent;
+ display: grid;
+ min-height: 26px;
+ min-width: 30px;
+ padding: 3px 6px;
+ place-items: center;
+}
+
+.timeline-current-frame {
+ color: #f4f7fa;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 12px;
+ min-width: 28px;
+ text-align: center;
+}
+
+.timeline-editor {
+ background:
+ linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
+ #0e1318;
+ background-size: 100% 24px;
+ border: 1px solid #2b3642;
+ border-radius: 6px;
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
+.timeline-strip {
+ padding: 0 0 6px;
+ position: relative;
+}
+
+.timeline-ruler,
+.timeline-track {
+ display: grid;
+}
+
+.timeline-ruler {
+ color: #8190a0;
+ font-size: 11px;
+ height: 24px;
+ padding: 0;
+}
+
+.timeline-ruler span {
+ align-items: center;
+ border-left: 1px solid #2f3b46;
+ display: flex;
+ gap: 5px;
+ padding-left: 7px;
+}
+
+.timeline-ruler i {
+ background: #4d5c69;
+ display: inline-block;
+ height: 8px;
+ width: 1px;
+}
+
+.timeline-track {
+ border-top: 1px solid #26323d;
+ padding: 6px;
+}
+
+.timeline-frame {
+ align-content: space-between;
+ background:
+ linear-gradient(90deg, rgba(101, 199, 255, 0.12), transparent 72%),
+ #1c2530;
+ border-color: #34414e;
+ border-left-width: 0;
+ border-radius: 0;
+ display: grid;
+ gap: 4px;
+ min-height: 68px;
+ min-width: 0;
+ padding: 7px 8px 8px;
+ text-align: left;
+}
+
+.timeline-frame:first-child {
+ border-left-width: 1px;
+ border-radius: 5px 0 0 5px;
+}
+
+.timeline-frame:last-child {
+ border-radius: 0 5px 5px 0;
+}
+
+.timeline-frame.active {
+ background:
+ linear-gradient(90deg, rgba(255, 216, 117, 0.18), transparent 76%),
+ #173348;
+ border-color: #65c7ff;
+ box-shadow:
+ inset 0 -3px 0 #65c7ff,
+ inset 0 1px 0 rgba(255, 255, 255, 0.1);
+}
+
+.timeline-frame-index {
+ color: #ffd875;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 10px;
+ letter-spacing: 0;
+}
+
+.timeline-frame-name {
+ color: #f4f7fa;
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.timeline-frame-duration {
+ color: #90a0af;
+ font-size: 11px;
+}
+
+.timeline-markers {
+ align-items: center;
+ display: flex;
+ gap: 4px;
+ min-height: 8px;
+}
+
+.timeline-playhead {
+ background: #ffd875;
+ bottom: 5px;
+ box-shadow: 0 0 0 1px rgba(16, 21, 27, 0.8);
+ position: absolute;
+ top: 0;
+ transform: translateX(-1px);
+ width: 2px;
+ z-index: 4;
+}
+
+.timeline-playhead::before {
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 7px solid #ffd875;
+ content: "";
+ left: -5px;
+ position: absolute;
+ top: 0;
+}
+
+.visually-hidden {
+ clip: rect(0 0 0 0);
+ clip-path: inset(50%);
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+}
+
+.marker {
+ border-radius: 999px;
+ display: inline-block;
+ height: 7px;
+ width: 18px;
+}
+
+.marker.hurt {
+ background: #66ff99;
+}
+
+.marker.hit {
+ background: #ff8b83;
+}
+
+.marker.projectile {
+ background: #d7c7ff;
+}
+
+.scale-guide rect {
+ fill: none;
+ pointer-events: none;
+ stroke-width: 1.4;
+}
+
+.scale-guide-min {
+ stroke: rgba(102, 255, 153, 0.72);
+}
+
+.scale-guide-max {
+ stroke: rgba(255, 216, 117, 0.78);
+ stroke-dasharray: 5 4;
+}
+
+.scale-guide text {
+ fill: #ffd875;
+ font-size: 9px;
+ paint-order: stroke;
+ stroke: #10151b;
+ stroke-width: 3px;
+}
+
+.statusbar {
+ align-items: center;
+ background: #171d24;
+ border-top: 1px solid #28323c;
+ color: #afbac5;
+ display: flex;
+ gap: 18px;
+ justify-content: space-between;
+ min-height: 38px;
+ padding: 8px 14px;
+ overflow: hidden;
+}
+
+.statusbar span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.box-card {
+ border: 1px solid #31404d;
+ border-radius: 6px;
+ display: grid;
+ gap: 8px;
+ padding: 10px;
+}
+
+.selected-card {
+ border-color: #65c7ff;
+ box-shadow: 0 0 0 1px rgba(101, 199, 255, 0.3);
+}
+
+.box-card-title {
+ align-items: center;
+ display: flex;
+ gap: 8px;
+ justify-content: space-between;
+}
+
+.row-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.box-card-title button:first-child {
+ flex: 1;
+ text-align: left;
+}
+
+.issue-list {
+ display: grid;
+ gap: 6px;
+ max-height: 180px;
+ overflow: auto;
+}
+
+.issue {
+ border: 1px solid #34424f;
+ border-radius: 6px;
+ display: grid;
+ gap: 4px;
+ padding: 8px;
+}
+
+.issue strong {
+ font-size: 11px;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.issue.error {
+ background: rgba(244, 106, 98, 0.12);
+ border-color: rgba(255, 139, 131, 0.5);
+}
+
+.issue.warning {
+ background: rgba(249, 199, 79, 0.1);
+ border-color: rgba(249, 199, 79, 0.45);
+}
+
+.ok {
+ color: #66ff99;
+}
+
+.warning {
+ color: #ffd875;
+}
+
+.runtime-validation {
+ border: 1px solid #34424f;
+ border-radius: 6px;
+ display: grid;
+ gap: 6px;
+ padding: 8px;
+}
+
+.runtime-validation code {
+ color: #aeb9c5;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 11px;
+ overflow-wrap: anywhere;
+}
+
+.runtime-validation pre {
+ background: #0e1216;
+ border: 1px solid #28323c;
+ border-radius: 6px;
+ color: #d4dce4;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+ font-size: 11px;
+ margin: 0;
+ max-height: 160px;
+ overflow: auto;
+ padding: 8px;
+ white-space: pre-wrap;
+}
+
+.summary-list {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.summary-list div {
+ background: #10151b;
+ border: 1px solid #34414e;
+ border-radius: 6px;
+ display: grid;
+ gap: 4px;
+ padding: 8px;
+}
+
+.summary-list dt {
+ color: #90a0af;
+ font-size: 11px;
+ overflow-wrap: anywhere;
+}
+
+.summary-list dd {
+ color: #f4f7fa;
+ font-weight: 700;
+}
+
+.summary-note {
+ color: #c6d0da;
+ font-size: 12px;
+}
+
+.ok-panel {
+ background: rgba(85, 209, 124, 0.1);
+ border-color: rgba(102, 255, 153, 0.42);
+}
+
+.error-panel {
+ background: rgba(244, 106, 98, 0.12);
+ border-color: rgba(255, 139, 131, 0.5);
+}
+
+.review-notes {
+ min-height: 92px;
+}
+
+.json-panel textarea {
+ height: 260px;
+}
+
+.help-backdrop {
+ align-items: center;
+ background: rgba(5, 8, 12, 0.72);
+ display: flex;
+ inset: 0;
+ justify-content: center;
+ padding: 28px;
+ position: fixed;
+ z-index: 80;
+}
+
+.help-window {
+ background: #151a20;
+ border: 1px solid #3a4653;
+ border-radius: 8px;
+ box-shadow: 0 22px 70px rgba(0, 0, 0, 0.56);
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ max-height: min(880px, calc(100vh - 56px));
+ max-width: 1120px;
+ overflow: hidden;
+ width: min(1120px, calc(100vw - 56px));
+}
+
+.help-header {
+ align-items: center;
+ background: #1b222b;
+ border-bottom: 1px solid #2b3642;
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 16px 18px;
+}
+
+.help-header h2 {
+ font-size: 18px;
+ text-transform: none;
+}
+
+.help-header p {
+ color: #90a0af;
+ margin-top: 4px;
+}
+
+.help-content {
+ display: grid;
+ gap: 16px;
+ overflow: auto;
+ padding: 18px;
+}
+
+.help-content article {
+ background: #1b222b;
+ border: 1px solid #2b3642;
+ border-radius: 8px;
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+}
+
+.help-content h3 {
+ color: #f4f7fa;
+ font-size: 16px;
+ margin: 0;
+}
+
+.help-content p,
+.help-content li {
+ color: #c6d0da;
+}
+
+.help-content p {
+ line-height: 1.55;
+}
+
+.help-content ul {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.help-content ol {
+ margin: 0;
+ padding-left: 18px;
+}
+
+.help-content code {
+ color: #ffd875;
+ font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
+}
+
+.help-callout {
+ background: rgba(101, 199, 255, 0.1);
+ border: 1px solid rgba(101, 199, 255, 0.36);
+ border-radius: 6px;
+ color: #d4dce4;
+ line-height: 1.5;
+ padding: 10px 12px;
+}
+
+.help-callout strong {
+ color: #f4f7fa;
+}
+
+.workflow-grid,
+.concept-grid {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.workflow-card,
+.concept-card {
+ background: #10151b;
+ border: 1px solid #34414e;
+ border-radius: 6px;
+ display: grid;
+ gap: 8px;
+ padding: 11px;
+}
+
+.workflow-card strong,
+.concept-card strong {
+ color: #f4f7fa;
+}
+
+.workflow-card li,
+.concept-card span {
+ color: #c6d0da;
+ line-height: 1.45;
+}
+
+.help-diagram {
+ background: #0f1318;
+ border: 1px solid #34414e;
+ border-radius: 6px;
+ height: auto;
+ max-height: 280px;
+ width: 100%;
+}
+
+.diagram-bg {
+ fill: #10151b;
+}
+
+.diagram-card {
+ fill: #1b222b;
+ stroke: #5a6978;
+ stroke-width: 3;
+}
+
+.diagram-card.accent {
+ fill: rgba(101, 199, 255, 0.15);
+ stroke: #65c7ff;
+}
+
+.diagram-source {
+ fill: rgba(249, 199, 79, 0.14);
+ stroke: #ffd875;
+ stroke-dasharray: 10 8;
+ stroke-width: 4;
+}
+
+.diagram-target {
+ fill: rgba(249, 199, 79, 0.08);
+ stroke: #c9ad64;
+ stroke-dasharray: 8 7;
+ stroke-width: 3;
+}
+
+.diagram-hurt {
+ fill: rgba(85, 209, 124, 0.24);
+ stroke: #66ff99;
+ stroke-width: 4;
+}
+
+.diagram-hit {
+ fill: rgba(244, 106, 98, 0.28);
+ stroke: #ff8b83;
+ stroke-width: 4;
+}
+
+.diagram-body {
+ fill: rgba(101, 199, 255, 0.2);
+ stroke: #65c7ff;
+ stroke-width: 4;
+}
+
+.diagram-body.muted {
+ opacity: 0.65;
+}
+
+.diagram-push {
+ fill: rgba(255, 216, 117, 0.12);
+ stroke: #ffd875;
+ stroke-dasharray: 9 6;
+ stroke-width: 3;
+}
+
+.diagram-pivot-line,
+.diagram-floor {
+ stroke: #f9c74f;
+ stroke-width: 4;
+}
+
+.diagram-pivot {
+ fill: #f9c74f;
+ stroke: #10151b;
+ stroke-width: 3;
+}
+
+.diagram-pivot.warning {
+ fill: #ff8b83;
+}
+
+.diagram-projectile {
+ fill: #d7c7ff;
+ stroke: #a78bfa;
+ stroke-width: 4;
+}
+
+.diagram-arrow {
+ fill: none;
+ stroke: #d7c7ff;
+ stroke-linecap: round;
+ stroke-width: 5;
+}
+
+.diagram-hit.ghost {
+ fill: rgba(244, 106, 98, 0.12);
+ stroke-dasharray: 9 7;
+}
+
+.help-diagram text {
+ fill: #f4f7fa;
+ font-size: 18px;
+ paint-order: stroke;
+ stroke: #10151b;
+ stroke-width: 5px;
+}
+
+.shortcut-grid {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.shortcut {
+ background: #10151b;
+ border: 1px solid #34414e;
+ border-radius: 6px;
+ display: grid;
+ gap: 4px;
+ padding: 10px;
+}
+
+.shortcut span {
+ color: #90a0af;
+ font-size: 12px;
+}
+
+.shortcut strong {
+ color: #e8edf2;
+}
+
+@media (max-width: 1180px) {
+ .workspace {
+ grid-template-columns: 260px minmax(400px, 1fr) 300px;
+ }
+
+ .workflow-grid,
+ .concept-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/tools/sprite-studio/src/App.tsx b/tools/sprite-studio/src/App.tsx
new file mode 100644
index 0000000..a25de31
--- /dev/null
+++ b/tools/sprite-studio/src/App.tsx
@@ -0,0 +1,2071 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { open, save as saveDialog } from "@tauri-apps/api/dialog";
+import { invoke } from "@tauri-apps/api/tauri";
+import {
+ DEFAULT_MANIFEST_PATH,
+ BODY_METRICS_PATH,
+ EDIT_MODES,
+ PRESET_MANIFESTS,
+ cloneManifest,
+ clipFrameNames,
+ clampRect,
+ defaultBox,
+ formatCoordinate,
+ frameByName,
+ inferCharacterId,
+ toNumber,
+} from "./manifest";
+import {
+ clampPoint,
+ handlePoints,
+ hitTestFrame,
+ localPointFromEvent,
+ moveRect,
+ resizeRect,
+ targetKey,
+} from "./geometry";
+import { validateManifest, visualScaleSummary } from "./validation";
+import { HelpCenter } from "./HelpCenter";
+import { MenuBar } from "./MenuBar";
+import { Timeline } from "./Timeline";
+import type {
+ BoxKind,
+ BodyMetricsCatalog,
+ CharacterBodyMetrics,
+ DragIntent,
+ DragState,
+ EditMode,
+ RuntimeValidationResult,
+ SaveResult,
+ SelectionTarget,
+ SpriteDocument,
+ SpriteFrame,
+ SpriteManifest,
+ SpritePoint,
+ SpriteRect,
+ TextDocument,
+ ValidationIssue,
+} from "./types";
+import "./App.css";
+
+const HISTORY_LIMIT = 60;
+type CombatPreset =
+ | "standing_hurt"
+ | "crouch_hurt"
+ | "mid_strike"
+ | "low_strike"
+ | "projectile_origin"
+ | "clear_frame";
+
+function App() {
+ const canvasRef = useRef(null);
+ const [manifestPath, setManifestPath] = useState(DEFAULT_MANIFEST_PATH);
+ const [document, setDocument] = useState(null);
+ const [manifest, setManifest] = useState(null);
+ const [selectedClip, setSelectedClip] = useState(null);
+ const [selectedFrame, setSelectedFrame] = useState(null);
+ const [editMode, setEditMode] = useState("inspect");
+ const [zoom, setZoom] = useState(2);
+ const [dirty, setDirty] = useState(false);
+ const [status, setStatus] = useState("Ready");
+ const [mouseLocal, setMouseLocal] = useState(null);
+ const [selection, setSelection] = useState(null);
+ const [dragState, setDragState] = useState(null);
+ const [undoStack, setUndoStack] = useState([]);
+ const [redoStack, setRedoStack] = useState([]);
+ const [showGrid, setShowGrid] = useState(true);
+ const [showBounds, setShowBounds] = useState(true);
+ const [showScaleGuide, setShowScaleGuide] = useState(true);
+ const [snapEnabled, setSnapEnabled] = useState(true);
+ const [snapStep, setSnapStep] = useState(4);
+ const [hoverIntent, setHoverIntent] = useState(null);
+ const [reviewNotes, setReviewNotes] = useState("");
+ const [runtimeValidation, setRuntimeValidation] =
+ useState(null);
+ const [runtimeValidationRunning, setRuntimeValidationRunning] =
+ useState(false);
+ const [leftPanelOpen, setLeftPanelOpen] = useState(true);
+ const [rightPanelOpen, setRightPanelOpen] = useState(true);
+ const [timelineOpen, setTimelineOpen] = useState(true);
+ const [helpOpen, setHelpOpen] = useState(false);
+ const [autosaveEnabled, setAutosaveEnabled] = useState(true);
+ const [lastAutosavePath, setLastAutosavePath] = useState(null);
+ const [lastBackupPath, setLastBackupPath] = useState(null);
+ const [bodyCatalog, setBodyCatalog] = useState(
+ null,
+ );
+ const [bodyMetricsPath, setBodyMetricsPath] = useState(BODY_METRICS_PATH);
+ const [selectedCharacterId, setSelectedCharacterId] = useState("rust");
+ const [bodyDirty, setBodyDirty] = useState(false);
+
+ const frameNames = useMemo(
+ () => clipFrameNames(manifest, selectedClip),
+ [manifest, selectedClip],
+ );
+ const timelineFrames = useMemo(
+ () =>
+ frameNames
+ .map((frameName) => frameByName(manifest, frameName))
+ .filter((frame): frame is SpriteFrame => !!frame)
+ .map((frame) => ({ ...frame, selected: frame.name === selectedFrame })),
+ [frameNames, manifest, selectedFrame],
+ );
+ const activeFrame = useMemo(
+ () => frameByName(manifest, selectedFrame),
+ [manifest, selectedFrame],
+ );
+ const manifestJson = useMemo(
+ () => (manifest ? JSON.stringify(manifest, null, 2) : ""),
+ [manifest],
+ );
+ const validationIssues = useMemo(
+ () => validateManifest(manifest),
+ [manifest],
+ );
+ const hasValidationErrors = validationIssues.some(
+ (issue) => issue.severity === "error",
+ );
+ const scaleSummary = useMemo(
+ () => visualScaleSummary(activeFrame, manifest?.scale ?? 1),
+ [activeFrame, manifest?.scale],
+ );
+
+ useEffect(() => {
+ void loadManifest(DEFAULT_MANIFEST_PATH);
+ }, []);
+
+ useEffect(() => {
+ drawFrame(document?.atlas_data_url, activeFrame, zoom, canvasRef.current);
+ }, [document?.atlas_data_url, activeFrame, zoom]);
+
+ useEffect(() => {
+ function onKeyDown(event: KeyboardEvent) {
+ const target = event.target as HTMLElement | null;
+ const isTextInput =
+ target?.tagName === "INPUT" || target?.tagName === "TEXTAREA";
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
+ event.preventDefault();
+ void saveManifest();
+ return;
+ }
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
+ event.preventDefault();
+ if (event.shiftKey) {
+ redo();
+ } else {
+ undo();
+ }
+ return;
+ }
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "y") {
+ event.preventDefault();
+ redo();
+ return;
+ }
+ if (!isTextInput && event.key === "ArrowLeft") {
+ event.preventDefault();
+ moveFrame(-1);
+ }
+ if (!isTextInput && event.key === "ArrowRight") {
+ event.preventDefault();
+ moveFrame(1);
+ }
+ if (!isTextInput && event.key === "F1") {
+ event.preventDefault();
+ setHelpOpen(true);
+ }
+ if (!isTextInput && (event.key === "=" || event.key === "+")) {
+ event.preventDefault();
+ changeZoom(0.25);
+ }
+ if (!isTextInput && event.key === "-") {
+ event.preventDefault();
+ changeZoom(-0.25);
+ }
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ });
+
+ useEffect(() => {
+ if (!autosaveEnabled || !dirty || !document || !manifest) {
+ return;
+ }
+ const timeout = window.setTimeout(() => {
+ void autosaveManifest();
+ }, 2500);
+ return () => window.clearTimeout(timeout);
+ }, [autosaveEnabled, dirty, document?.manifest_path, manifestJson]);
+
+ async function loadManifest(path: string) {
+ try {
+ setStatus(`Loading ${path}`);
+ const loaded = await invoke("load_sprite_manifest", {
+ path,
+ });
+ const parsed = JSON.parse(loaded.manifest_json) as SpriteManifest;
+ const firstClip = parsed.clips[0]?.name ?? null;
+ const firstFrame =
+ parsed.clips[0]?.frames[0] ?? parsed.frames[0]?.name ?? null;
+
+ setDocument(loaded);
+ setManifest(parsed);
+ setManifestPath(loaded.manifest_path);
+ setSelectedCharacterId(inferCharacterId(loaded.manifest_path));
+ setSelectedClip(firstClip);
+ setSelectedFrame(firstFrame);
+ setSelection(null);
+ setUndoStack([]);
+ setRedoStack([]);
+ setDirty(false);
+ setStatus(`Loaded ${loaded.manifest_path}`);
+ void loadBodyMetrics();
+ } catch (error) {
+ setStatus(errorMessage(error));
+ }
+ }
+
+ async function chooseManifestFile() {
+ try {
+ const selected = await open({
+ defaultPath: `${document?.repo_root ?? ""}/assets/placeholder`,
+ filters: [{ name: "Borrow Fighters sprite manifest", extensions: ["json"] }],
+ multiple: false,
+ title: "Open sprite manifest",
+ });
+ const path = dialogPath(selected);
+ if (!path) {
+ setStatus("Open cancelled.");
+ return;
+ }
+ await loadManifest(path);
+ } catch (error) {
+ setStatus(errorMessage(error));
+ }
+ }
+
+ async function loadBodyMetrics(path = BODY_METRICS_PATH) {
+ try {
+ const loaded = await invoke("load_repo_text_file", { path });
+ const parsed = JSON.parse(loaded.contents) as BodyMetricsCatalog;
+ setBodyCatalog(parsed);
+ setBodyMetricsPath(loaded.path);
+ setBodyDirty(false);
+ } catch (error) {
+ setStatus(`Body metrics not loaded: ${errorMessage(error)}`);
+ }
+ }
+
+ async function saveBodyMetrics() {
+ if (!bodyCatalog) {
+ return;
+ }
+ const invalid = bodyCatalog.characters.some(
+ (entry) =>
+ entry.body.width <= 0 ||
+ entry.body.standing_height <= 0 ||
+ entry.body.crouch_height <= 0,
+ );
+ if (invalid) {
+ setStatus("Fix body metrics before saving.");
+ return;
+ }
+ try {
+ const contents = `${JSON.stringify(bodyCatalog, null, 2)}\n`;
+ const result = await invoke("save_repo_text_file", {
+ path: bodyMetricsPath,
+ contents,
+ });
+ setBodyDirty(false);
+ setStatus(`Saved ${result.path} (${result.bytes_written} bytes)`);
+ } catch (error) {
+ setStatus(errorMessage(error));
+ }
+ }
+
+ async function runRuntimeValidation() {
+ try {
+ setRuntimeValidationRunning(true);
+ setStatus("Running runtime sprite validation...");
+ const result = await invoke(
+ "validate_runtime_assets",
+ );
+ setRuntimeValidation(result);
+ setStatus(
+ result.success
+ ? `Runtime validation passed: ${result.command}`
+ : `Runtime validation failed: ${result.command}`,
+ );
+ } catch (error) {
+ setStatus(errorMessage(error));
+ } finally {
+ setRuntimeValidationRunning(false);
+ }
+ }
+
+ async function exportReviewJson() {
+ if (!document || !manifest || !activeFrame) {
+ return;
+ }
+ const fileName = `${activeFrame.name}-sprite-studio-review.json`;
+ try {
+ const selected = await saveDialog({
+ defaultPath: `${document.repo_root}/${fileName}`,
+ filters: [{ name: "Sprite Studio review", extensions: ["json"] }],
+ title: "Export Sprite Studio review JSON",
+ });
+ if (!selected) {
+ setStatus("Review export cancelled.");
+ return;
+ }
+ const payload = {
+ schema: "borrow-fighters.sprite-studio-review.v1",
+ created_by: "Borrow Fighters Sprite Studio",
+ manifest_path: document.manifest_path,
+ atlas_path: document.atlas_path,
+ clip: selectedClip,
+ frame: activeFrame.name,
+ frame_data: activeFrame,
+ manifest_scale: manifest.scale ?? 1,
+ visual_scale: scaleSummary,
+ body_metrics_character: selectedCharacterId,
+ body_metrics: activeBodyMetrics,
+ validation_issues: validationIssues,
+ runtime_validation: runtimeValidation,
+ notes: reviewNotes,
+ };
+ const result = await invoke("save_repo_text_file", {
+ path: selected,
+ contents: `${JSON.stringify(payload, null, 2)}\n`,
+ });
+ setStatus(`Exported ${result.path}`);
+ } catch (error) {
+ setStatus(errorMessage(error));
+ }
+ }
+
+ async function autosaveManifest() {
+ if (!document || !manifest) {
+ return;
+ }
+ try {
+ const result = await invoke("autosave_sprite_manifest", {
+ path: document.manifest_path,
+ manifestJson,
+ });
+ setLastAutosavePath(result.path);
+ setStatus(`Autosaved ${result.path}`);
+ } catch (error) {
+ setStatus(`Autosave failed: ${errorMessage(error)}`);
+ }
+ }
+
+ async function saveManifest() {
+ if (!document || !manifest) {
+ return;
+ }
+ if (hasValidationErrors) {
+ setStatus("Fix validation errors before saving.");
+ return;
+ }
+ try {
+ const result = await invoke("save_sprite_manifest", {
+ path: document.manifest_path,
+ manifestJson,
+ });
+ setDirty(false);
+ setLastBackupPath(result.backup_path ?? null);
+ setStatus(
+ result.backup_path
+ ? `Saved ${result.path}; backup ${result.backup_path}`
+ : `Saved ${result.path} (${result.bytes_written} bytes)`,
+ );
+ } catch (error) {
+ setStatus(errorMessage(error));
+ }
+ }
+
+ function pushHistory(snapshot = manifest) {
+ if (!snapshot) {
+ return;
+ }
+ setUndoStack((stack) => [
+ ...stack.slice(Math.max(0, stack.length - HISTORY_LIMIT + 1)),
+ cloneManifest(snapshot),
+ ]);
+ setRedoStack([]);
+ }
+
+ function updateManifest(
+ mutator: (next: SpriteManifest) => void,
+ options: { history?: boolean } = {},
+ ) {
+ if (!manifest) {
+ return;
+ }
+ if (options.history !== false) {
+ pushHistory(manifest);
+ }
+ const next = cloneManifest(manifest);
+ mutator(next);
+ setManifest(next);
+ setDirty(true);
+ }
+
+ function updateActiveFrame(
+ mutator: (frame: SpriteFrame) => void,
+ options: { history?: boolean } = {},
+ ) {
+ if (!activeFrame) {
+ return;
+ }
+ updateManifest((next) => {
+ const nextFrame = next.frames.find(
+ (frame) => frame.name === activeFrame.name,
+ );
+ if (nextFrame) {
+ mutator(nextFrame);
+ }
+ }, options);
+ }
+
+ function undo() {
+ if (!manifest || undoStack.length === 0) {
+ return;
+ }
+ const previous = undoStack[undoStack.length - 1];
+ setUndoStack((stack) => stack.slice(0, -1));
+ setRedoStack((stack) => [...stack, cloneManifest(manifest)]);
+ setManifest(cloneManifest(previous));
+ setDirty(true);
+ setStatus("Undo");
+ }
+
+ function redo() {
+ if (!manifest || redoStack.length === 0) {
+ return;
+ }
+ const next = redoStack[redoStack.length - 1];
+ setRedoStack((stack) => stack.slice(0, -1));
+ setUndoStack((stack) => [...stack, cloneManifest(manifest)]);
+ setManifest(cloneManifest(next));
+ setDirty(true);
+ setStatus("Redo");
+ }
+
+ function handleClipSelection(clipName: string) {
+ const names = clipFrameNames(manifest, clipName);
+ setSelectedClip(clipName);
+ setSelectedFrame(names[0] ?? null);
+ setSelection(null);
+ }
+
+ function moveFrame(delta: number) {
+ if (!frameNames.length || !selectedFrame) {
+ return;
+ }
+ const index = frameNames.indexOf(selectedFrame);
+ const nextIndex = (index + delta + frameNames.length) % frameNames.length;
+ setSelectedFrame(frameNames[nextIndex]);
+ setSelection(null);
+ }
+
+ function changeZoom(delta: number) {
+ setZoom((current) => Math.min(5, Math.max(0.5, current + delta)));
+ }
+
+ function handleStageWheel(event: React.WheelEvent) {
+ event.preventDefault();
+ changeZoom(event.deltaY < 0 ? 0.25 : -0.25);
+ }
+
+ function snapPoint(point: SpritePoint, frame: SpriteFrame): SpritePoint {
+ if (!snapEnabled || snapStep <= 1) {
+ return clampPoint(point, frame);
+ }
+ return clampPoint(
+ {
+ x: Math.round(point.x / snapStep) * snapStep,
+ y: Math.round(point.y / snapStep) * snapStep,
+ },
+ frame,
+ );
+ }
+
+ function seedCombatPreset(preset: CombatPreset) {
+ if (!activeFrame) {
+ return;
+ }
+ const scale = manifest?.scale ?? 1;
+ const metrics = activeBodyMetrics ?? {
+ width: 76,
+ standing_height: 168,
+ crouch_height: 96,
+ };
+ updateActiveFrame((frame) => {
+ frame.combat ??= {};
+ const width = metrics.width / scale;
+ const standing = metrics.standing_height / scale;
+ const crouch = metrics.crouch_height / scale;
+ const centerX = frame.pivot.x;
+ const floorY = frame.pivot.y;
+
+ if (preset === "clear_frame") {
+ frame.combat = {};
+ return;
+ }
+ if (preset === "standing_hurt") {
+ frame.combat.hurtboxes = [
+ clampRect(
+ {
+ x: centerX - width / 2,
+ y: floorY - standing,
+ w: width,
+ h: standing,
+ label: "body",
+ },
+ frame,
+ ),
+ ];
+ }
+ if (preset === "crouch_hurt") {
+ frame.combat.hurtboxes = [
+ clampRect(
+ {
+ x: centerX - width * 0.58,
+ y: floorY - crouch,
+ w: width * 1.16,
+ h: crouch,
+ label: "crouch_body",
+ },
+ frame,
+ ),
+ ];
+ }
+ if (preset === "mid_strike") {
+ frame.combat.hitboxes = [
+ clampRect(
+ {
+ x: centerX + width * 0.32,
+ y: floorY - standing * 0.68,
+ w: 54 / scale,
+ h: 34 / scale,
+ label: "mid_strike",
+ },
+ frame,
+ ),
+ ];
+ }
+ if (preset === "low_strike") {
+ frame.combat.hitboxes = [
+ clampRect(
+ {
+ x: centerX + width * 0.26,
+ y: floorY - standing * 0.36,
+ w: 68 / scale,
+ h: 30 / scale,
+ label: "low_strike",
+ },
+ frame,
+ ),
+ ];
+ }
+ if (preset === "projectile_origin") {
+ frame.combat.projectile_origin = clampPoint(
+ {
+ x: centerX + width * 0.34,
+ y: floorY - standing * 0.63,
+ },
+ frame,
+ );
+ }
+ });
+ }
+
+ function beginStageEdit(event: React.PointerEvent) {
+ if (!activeFrame) {
+ return;
+ }
+ const rawPoint = localPointFromEvent(event, zoom);
+ const point = snapPoint(rawPoint, activeFrame);
+ setMouseLocal(rawPoint);
+
+ const hit = hitTestFrame(activeFrame, rawPoint);
+ if (hit) {
+ beginDrag(event, hit, point);
+ return;
+ }
+
+ if (editMode === "pivot") {
+ const intent: DragIntent = { action: "move", target: { type: "pivot" } };
+ const nextPivot = clampPoint(point, activeFrame);
+ pushHistory();
+ updateActiveFrame((frame) => {
+ frame.pivot = nextPivot;
+ }, { history: false });
+ setSelection(intent.target);
+ setDragState({
+ ...intent,
+ startMouse: point,
+ startPivot: nextPivot,
+ });
+ event.currentTarget.setPointerCapture(event.pointerId);
+ return;
+ }
+
+ if (editMode === "projectile") {
+ const intent: DragIntent = {
+ action: "move",
+ target: { type: "projectile" },
+ };
+ const nextProjectile = clampPoint(point, activeFrame);
+ pushHistory();
+ updateActiveFrame((frame) => {
+ frame.combat ??= {};
+ frame.combat.projectile_origin = nextProjectile;
+ }, { history: false });
+ setSelection(intent.target);
+ setDragState({
+ ...intent,
+ startMouse: point,
+ startProjectile: nextProjectile,
+ });
+ event.currentTarget.setPointerCapture(event.pointerId);
+ return;
+ }
+
+ if (editMode === "hurtbox" || editMode === "hitbox") {
+ const kind = editMode === "hurtbox" ? "hurtboxes" : "hitboxes";
+ let addedIndex = 0;
+ updateActiveFrame((frame) => {
+ frame.combat ??= {};
+ frame.combat[kind] ??= [];
+ const boxes = frame.combat[kind] ?? [];
+ boxes.push(defaultBox(point.x, point.y, frame, editMode));
+ addedIndex = boxes.length - 1;
+ });
+ setSelection({ type: "box", kind, index: addedIndex });
+ return;
+ }
+
+ setSelection(null);
+ }
+
+ function beginDrag(
+ event: React.PointerEvent,
+ intent: DragIntent,
+ point: SpritePoint,
+ ) {
+ if (!activeFrame) {
+ return;
+ }
+ pushHistory();
+ setSelection(intent.target);
+ const base = {
+ ...intent,
+ startMouse: point,
+ startPivot:
+ intent.target.type === "pivot" ? { ...activeFrame.pivot } : undefined,
+ startProjectile:
+ intent.target.type === "projectile" &&
+ activeFrame.combat?.projectile_origin
+ ? { ...activeFrame.combat.projectile_origin }
+ : undefined,
+ startRect:
+ intent.target.type === "box"
+ ? {
+ ...((activeFrame.combat?.[intent.target.kind] ?? [])[
+ intent.target.index
+ ] as SpriteRect),
+ }
+ : undefined,
+ };
+ setDragState(base);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ }
+
+ function continueStageEdit(event: React.PointerEvent) {
+ if (!activeFrame) {
+ return;
+ }
+ const rawPoint = localPointFromEvent(event, zoom);
+ const point = snapPoint(rawPoint, activeFrame);
+ setMouseLocal(rawPoint);
+ if (!dragState) {
+ setHoverIntent(hitTestFrame(activeFrame, rawPoint));
+ return;
+ }
+ setHoverIntent(null);
+ const delta = {
+ x: point.x - dragState.startMouse.x,
+ y: point.y - dragState.startMouse.y,
+ };
+
+ updateActiveFrame((frame) => {
+ if (dragState.target.type === "pivot" && dragState.startPivot) {
+ frame.pivot = clampPoint(
+ {
+ x: dragState.startPivot.x + delta.x,
+ y: dragState.startPivot.y + delta.y,
+ },
+ frame,
+ );
+ }
+ if (
+ dragState.target.type === "projectile" &&
+ dragState.startProjectile
+ ) {
+ frame.combat ??= {};
+ frame.combat.projectile_origin = clampPoint(
+ {
+ x: dragState.startProjectile.x + delta.x,
+ y: dragState.startProjectile.y + delta.y,
+ },
+ frame,
+ );
+ }
+ if (dragState.target.type === "box" && dragState.startRect) {
+ const boxes = frame.combat?.[dragState.target.kind];
+ if (!boxes?.[dragState.target.index]) {
+ return;
+ }
+ boxes[dragState.target.index] =
+ dragState.action === "resize"
+ ? resizeRect(dragState.startRect, dragState.handle, delta, frame)
+ : moveRect(dragState.startRect, delta, frame);
+ }
+ }, { history: false });
+ }
+
+ function endStageEdit(event: React.PointerEvent) {
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+ setDragState(null);
+ }
+
+ function leaveStage() {
+ setHoverIntent(null);
+ setMouseLocal(null);
+ }
+
+ function updateBox(
+ kind: BoxKind,
+ index: number,
+ patch: Partial,
+ ) {
+ updateActiveFrame((frame) => {
+ const boxes = frame.combat?.[kind];
+ if (!boxes?.[index]) {
+ return;
+ }
+ boxes[index] = { ...boxes[index], ...patch };
+ });
+ }
+
+ function updateBodyMetrics(patch: Partial) {
+ if (!bodyCatalog) {
+ return;
+ }
+ const next = JSON.parse(JSON.stringify(bodyCatalog)) as BodyMetricsCatalog;
+ let entry = next.characters.find((item) => item.id === selectedCharacterId);
+ if (!entry) {
+ entry = {
+ id: selectedCharacterId,
+ body: { width: 76, standing_height: 168, crouch_height: 96 },
+ };
+ next.characters.push(entry);
+ }
+ entry.body = { ...entry.body, ...patch };
+ setBodyCatalog(next);
+ setBodyDirty(true);
+ }
+
+ function removeBox(kind: BoxKind, index: number) {
+ updateActiveFrame((frame) => {
+ frame.combat?.[kind]?.splice(index, 1);
+ });
+ if (selection?.type === "box" && selection.kind === kind && selection.index === index) {
+ setSelection(null);
+ }
+ }
+
+ async function exportReviewImage() {
+ if (!document?.atlas_data_url || !activeFrame) {
+ return;
+ }
+ try {
+ const dataUrl = await makeReviewImage(
+ document.atlas_data_url,
+ activeFrame,
+ zoom,
+ showBounds,
+ );
+ const link = window.document.createElement("a");
+ link.href = dataUrl;
+ link.download = `${activeFrame.name}-sprite-studio-review.png`;
+ link.click();
+ setStatus(`Exported ${link.download}`);
+ } catch (error) {
+ setStatus(errorMessage(error));
+ }
+ }
+
+ const canvasWidth = activeFrame ? activeFrame.frame.w * zoom : 0;
+ const canvasHeight = activeFrame ? activeFrame.frame.h * zoom : 0;
+ const frameIndex = selectedFrame ? frameNames.indexOf(selectedFrame) : -1;
+ const selectedKey = targetKey(selection);
+ const activeBodyMetrics =
+ bodyCatalog?.characters.find((entry) => entry.id === selectedCharacterId)
+ ?.body ?? null;
+ const stageCursor = cursorForIntent(dragState ?? hoverIntent, editMode);
+ const workspaceClassName = [
+ "workspace",
+ leftPanelOpen ? "" : "left-collapsed",
+ rightPanelOpen ? "" : "right-collapsed",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+
+
+ 0}
+ canSave={dirty && !hasValidationErrors}
+ canUndo={undoStack.length > 0}
+ leftPanelOpen={leftPanelOpen}
+ rightPanelOpen={rightPanelOpen}
+ timelineOpen={timelineOpen}
+ onExportPng={() => void exportReviewImage()}
+ onExportReview={() => void exportReviewJson()}
+ onHelp={() => setHelpOpen(true)}
+ onOpen={() => void chooseManifestFile()}
+ onRedo={redo}
+ onReload={() => void loadManifest(manifestPath)}
+ onRunValidation={() => void runRuntimeValidation()}
+ onSave={() => void saveManifest()}
+ onToggleAutosave={() => setAutosaveEnabled((enabled) => !enabled)}
+ onToggleLeftPanel={() => setLeftPanelOpen((open) => !open)}
+ onToggleRightPanel={() => setRightPanelOpen((open) => !open)}
+ onToggleTimeline={() => setTimelineOpen((open) => !open)}
+ onUndo={undo}
+ />
+
+
+ void chooseManifestFile()}
+ >
+
+ Open
+
+ void saveManifest()}
+ >
+
+ Save
+
+
+
+
+
+
+ Undo
+
+
+
+ Redo
+
+
+
+
+ {manifestPath}
+
+
+ setLeftPanelOpen((open) => !open)}
+ >
+
+ Browser
+
+ setRightPanelOpen((open) => !open)}
+ >
+
+ Inspector
+
+ setTimelineOpen((open) => !open)}
+ >
+
+ Timeline
+
+ setHelpOpen(true)}
+ >
+
+ Help
+
+
+
+
+
+
+ {leftPanelOpen && (
+
+
+
+
+ Clips
+
+ {manifest?.clips.map((clip) => (
+ handleClipSelection(clip.name)}
+ >
+ {clip.name}
+ {clip.frames.length} frames
+
+ ))}
+
+
+
+
+ Frames
+
+ {frameIndex >= 0 ? frameIndex + 1 : 0} / {frameNames.length}
+
+
+ {frameNames.map((frameName) => (
+ {
+ setSelectedFrame(frameName);
+ setSelection(null);
+ }}
+ >
+ {frameName}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ {activeFrame && (
+
+
+
+
+
+
+
+
+ {showGrid && (
+
+ )}
+ {showBounds && renderRect(activeFrame.trimmed_bounds, "trimmed")}
+ {showBounds && renderRect(activeFrame.source_crop, "source")}
+ {showScaleGuide &&
+ renderScaleGuide(activeFrame, manifest?.scale ?? 1)}
+ {activeFrame.combat?.hurtboxes?.map((box, index) =>
+ renderEditableRect(
+ box,
+ "hurt",
+ { type: "box", kind: "hurtboxes", index },
+ selectedKey,
+ ),
+ )}
+ {activeFrame.combat?.hitboxes?.map((box, index) =>
+ renderEditableRect(
+ box,
+ "hit",
+ { type: "box", kind: "hitboxes", index },
+ selectedKey,
+ ),
+ )}
+
+
+
+ {activeFrame.combat?.projectile_origin && (
+
+
+
+
+
+ )}
+
+
+ )}
+
+
+ {timelineOpen && (
+ moveFrame(1)}
+ onPrevious={() => moveFrame(-1)}
+ onSelect={(frameName) => {
+ setSelectedFrame(frameName);
+ setSelection(null);
+ }}
+ />
+ )}
+
+
+ {status}
+ {dirty ? "Unsaved changes" : "Clean"}
+ {autosaveEnabled ? "Autosave on" : "Autosave off"}
+ autosave {lastAutosavePath ?? "-"}
+ backup {lastBackupPath ?? "-"}
+
+ selected {selectedKey || "-"} | local{" "}
+ {formatCoordinate(mouseLocal?.x ?? null)},{" "}
+ {formatCoordinate(mouseLocal?.y ?? null)}
+
+
+
+
+ {rightPanelOpen && (
+
+
+ Frame Data
+ {activeFrame ? (
+
+
+ Manifest scale
+
+ updateManifest((next) => {
+ next.scale = toNumber(event.currentTarget.value, 1);
+ })
+ }
+ />
+
+
+ Pivot X
+
+ updateActiveFrame((frame) => {
+ frame.pivot.x = Math.round(
+ toNumber(event.currentTarget.value, frame.pivot.x),
+ );
+ })
+ }
+ />
+
+
+ Pivot Y
+
+ updateActiveFrame((frame) => {
+ frame.pivot.y = Math.round(
+ toNumber(event.currentTarget.value, frame.pivot.y),
+ );
+ })
+ }
+ />
+
+
+ Duration ms
+
+ updateActiveFrame((frame) => {
+ frame.duration_ms = Math.max(
+ 1,
+ Math.round(
+ toNumber(
+ event.currentTarget.value,
+ frame.duration_ms,
+ ),
+ ),
+ );
+ })
+ }
+ />
+
+
+ ) : (
+ No frame selected.
+ )}
+
+
+
+
+
+
+ void loadBodyMetrics(bodyMetricsPath)}
+ onSave={() => void saveBodyMetrics()}
+ onUpdate={updateBodyMetrics}
+ selectedCharacterId={selectedCharacterId}
+ />
+
+
+
+
+
+ setSelection(target)}
+ onUpdate={updateBox}
+ selection={selection}
+ title="Hurtboxes"
+ />
+ setSelection(target)}
+ onUpdate={updateBox}
+ selection={selection}
+ title="Hitboxes"
+ />
+
+ void runRuntimeValidation()}
+ runtimeResult={runtimeValidation}
+ runtimeRunning={runtimeValidationRunning}
+ />
+
+ void exportReviewJson()}
+ onNotesChange={setReviewNotes}
+ />
+
+
+
+ )}
+
+ setHelpOpen(false)} />
+
+ );
+}
+
+function FrameCombatSummary({
+ activeFrame,
+}: {
+ activeFrame: SpriteFrame | null;
+}) {
+ const hurtboxes = activeFrame?.combat?.hurtboxes ?? [];
+ const hitboxes = activeFrame?.combat?.hitboxes ?? [];
+ const projectileOrigin = activeFrame?.combat?.projectile_origin ?? null;
+ const hasCombat =
+ hurtboxes.length > 0 || hitboxes.length > 0 || projectileOrigin !== null;
+
+ return (
+
+ Frame Combat
+ {activeFrame ? (
+ <>
+
+
+
Hurtboxes
+ {hurtboxes.length}
+
+
+
Hitboxes
+ {hitboxes.length}
+
+
+
Projectile origin
+ {projectileOrigin ? "yes" : "no"}
+
+
+ {!hasCombat && (
+
+ No frame metadata here. The game uses runtime fallback boxes for
+ this frame.
+
+ )}
+ {projectileOrigin && hitboxes.length === 0 && (
+
+ Projectile damage boxes are still defined by the Rust
+ ProjectileSpec. This frame only stores where the projectile starts.
+
+ )}
+ >
+ ) : (
+ No frame selected.
+ )}
+
+ );
+}
+
+function BodyMetricsPanel({
+ bodyDirty,
+ bodyMetrics,
+ bodyMetricsPath,
+ catalog,
+ onCharacterChange,
+ onReload,
+ onSave,
+ onUpdate,
+ selectedCharacterId,
+}: {
+ bodyDirty: boolean;
+ bodyMetrics: CharacterBodyMetrics | null;
+ bodyMetricsPath: string;
+ catalog: BodyMetricsCatalog | null;
+ onCharacterChange: (id: string) => void;
+ onReload: () => void;
+ onSave: () => void;
+ onUpdate: (patch: Partial) => void;
+ selectedCharacterId: string;
+}) {
+ const characterIds =
+ catalog?.characters.map((entry) => entry.id) ?? ["rust", "duke", "go", "c"];
+ return (
+
+ );
+}
+
+function ScalePanel({
+ summary,
+}: {
+ summary: ReturnType;
+}) {
+ return (
+
+ Visual Scale
+ {summary ? (
+
+ Visible width
+
+ {summary.width.toFixed(1)} px
+
+ Visible height
+
+ {summary.height.toFixed(1)} px
+
+ Target
+ 110-150 px wide, 185-210 px tall
+ Status
+ {summary.message}
+
+ ) : (
+ No `trimmed_bounds` or `source_crop` data on this frame.
+ )}
+
+ );
+}
+
+function CombatPresetPanel({
+ disabled,
+ onSeed,
+}: {
+ disabled: boolean;
+ onSeed: (preset: CombatPreset) => void;
+}) {
+ const presets: Array<{ id: CombatPreset; label: string }> = [
+ { id: "standing_hurt", label: "Standing Hurtbox" },
+ { id: "crouch_hurt", label: "Crouch Hurtbox" },
+ { id: "mid_strike", label: "Mid Strike" },
+ { id: "low_strike", label: "Low Strike" },
+ { id: "projectile_origin", label: "Projectile Origin" },
+ { id: "clear_frame", label: "Clear Combat Data" },
+ ];
+ return (
+
+ Combat Presets
+
+ {presets.map((preset) => (
+ onSeed(preset.id)}
+ >
+ {preset.label}
+
+ ))}
+
+
+ );
+}
+
+function ValidationPanel({
+ issues,
+ onRunRuntime,
+ runtimeResult,
+ runtimeRunning,
+}: {
+ issues: ValidationIssue[];
+ onRunRuntime: () => void;
+ runtimeResult: RuntimeValidationResult | null;
+ runtimeRunning: boolean;
+}) {
+ const errors = issues.filter((issue) => issue.severity === "error").length;
+ const warnings = issues.filter((issue) => issue.severity === "warning").length;
+ return (
+
+ Validation
+
+ {errors} errors, {warnings} warnings
+
+
+ {issues.map((issue, index) => (
+
+ {issue.severity}
+
+ {issue.frameName ? `${issue.frameName}: ` : ""}
+ {issue.message}
+
+
+ ))}
+
+
+ {runtimeRunning ? "Running..." : "Run Runtime Validation"}
+
+ {runtimeResult && (
+
+
{runtimeResult.success ? "Runtime OK" : "Runtime Failed"}
+
{runtimeResult.command}
+ {(runtimeResult.stderr || runtimeResult.stdout) && (
+
+ {(runtimeResult.stderr || runtimeResult.stdout).slice(0, 1800)}
+
+ )}
+
+ )}
+
+ );
+}
+
+function ReviewPanel({
+ notes,
+ onExport,
+ onNotesChange,
+}: {
+ notes: string;
+ onExport: () => void;
+ onNotesChange: (notes: string) => void;
+}) {
+ return (
+
+ Review Package
+
+ Notes
+
+ Export Review JSON
+
+ );
+}
+
+function BoxEditor({
+ activeFrame,
+ kind,
+ onRemove,
+ onSelect,
+ onUpdate,
+ selection,
+ title,
+}: {
+ activeFrame: SpriteFrame | null;
+ kind: BoxKind;
+ onRemove: (kind: BoxKind, index: number) => void;
+ onSelect: (target: SelectionTarget) => void;
+ onUpdate: (kind: BoxKind, index: number, patch: Partial) => void;
+ selection: SelectionTarget | null;
+ title: string;
+}) {
+ const boxes = activeFrame?.combat?.[kind] ?? [];
+
+ return (
+
+ {title}
+ {boxes.length === 0 && No boxes on this frame.
}
+ {boxes.map((box, index) => {
+ const selected =
+ selection?.type === "box" &&
+ selection.kind === kind &&
+ selection.index === index;
+ return (
+
+
+ onSelect({ type: "box", kind, index })}>
+ {box.label || `${kind}-${index + 1}`}
+
+ onRemove(kind, index)}>Remove
+
+
+ Label
+
+ onUpdate(kind, index, { label: event.currentTarget.value })
+ }
+ />
+
+
+ {(["x", "y", "w", "h"] as const).map((field) => (
+
+ {field.toUpperCase()}
+
+ onUpdate(kind, index, {
+ [field]: Math.round(
+ toNumber(event.currentTarget.value, box[field]),
+ ),
+ })
+ }
+ />
+
+ ))}
+
+
+ );
+ })}
+
+ );
+}
+
+function renderEditableRect(
+ rect: SpriteRect,
+ className: string,
+ target: Extract,
+ selectedKey: string,
+) {
+ const selected = targetKey(target) === selectedKey;
+ return (
+
+
+ {rect.label && (
+
+ {rect.label}
+
+ )}
+ {selected &&
+ handlePoints(rect).map(({ handle, point }) => (
+
+ ))}
+
+ );
+}
+
+function renderRect(rect: SpriteRect | undefined, className: string) {
+ if (!rect) {
+ return null;
+ }
+ return (
+
+
+ {rect.label && (
+
+ {rect.label}
+
+ )}
+
+ );
+}
+
+function renderScaleGuide(frame: SpriteFrame, scale: number) {
+ const safeScale = scale > 0 ? scale : 1;
+ const targetMin = {
+ w: 110 / safeScale,
+ h: 185 / safeScale,
+ };
+ const targetMax = {
+ w: 150 / safeScale,
+ h: 210 / safeScale,
+ };
+ const minRect = {
+ x: frame.pivot.x - targetMin.w / 2,
+ y: frame.pivot.y - targetMin.h,
+ w: targetMin.w,
+ h: targetMin.h,
+ };
+ const maxRect = {
+ x: frame.pivot.x - targetMax.w / 2,
+ y: frame.pivot.y - targetMax.h,
+ w: targetMax.w,
+ h: targetMax.h,
+ };
+ return (
+
+
+
+
+ target visual range
+
+
+ );
+}
+
+function drawFrame(
+ atlasDataUrl: string | undefined,
+ frame: SpriteFrame | null,
+ zoom: number,
+ canvas: HTMLCanvasElement | null,
+) {
+ if (!atlasDataUrl || !frame || !canvas) {
+ return;
+ }
+
+ const context = canvas.getContext("2d");
+ if (!context) {
+ return;
+ }
+
+ const image = new Image();
+ image.onload = () => {
+ canvas.width = Math.round(frame.frame.w * zoom);
+ canvas.height = Math.round(frame.frame.h * zoom);
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ context.imageSmoothingEnabled = false;
+ context.drawImage(
+ image,
+ frame.frame.x,
+ frame.frame.y,
+ frame.frame.w,
+ frame.frame.h,
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ );
+ };
+ image.src = atlasDataUrl;
+}
+
+function makeReviewImage(
+ atlasDataUrl: string,
+ frame: SpriteFrame,
+ zoom: number,
+ showBounds: boolean,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => {
+ const width = Math.round(frame.frame.w * zoom);
+ const height = Math.round(frame.frame.h * zoom);
+ const canvas = window.document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext("2d");
+ if (!context) {
+ reject(new Error("Could not create export canvas."));
+ return;
+ }
+ context.imageSmoothingEnabled = false;
+ context.drawImage(
+ image,
+ frame.frame.x,
+ frame.frame.y,
+ frame.frame.w,
+ frame.frame.h,
+ 0,
+ 0,
+ width,
+ height,
+ );
+ drawReviewOverlay(context, frame, zoom, showBounds);
+ resolve(canvas.toDataURL("image/png"));
+ };
+ image.onerror = () => reject(new Error("Could not load atlas image."));
+ image.src = atlasDataUrl;
+ });
+}
+
+function drawReviewOverlay(
+ context: CanvasRenderingContext2D,
+ frame: SpriteFrame,
+ zoom: number,
+ showBounds: boolean,
+) {
+ context.save();
+ context.scale(zoom, zoom);
+ context.lineWidth = 2 / zoom;
+ if (showBounds) {
+ strokeRect(context, frame.trimmed_bounds, "#6cc7ff", [6, 4]);
+ strokeRect(context, frame.source_crop, "#ffd875", [3, 3]);
+ }
+ for (const box of frame.combat?.hurtboxes ?? []) {
+ fillStrokeRect(context, box, "rgba(85,209,124,0.22)", "#66ff99");
+ }
+ for (const box of frame.combat?.hitboxes ?? []) {
+ fillStrokeRect(context, box, "rgba(244,106,98,0.24)", "#ff8b83");
+ }
+ context.setLineDash([]);
+ context.strokeStyle = "#f9c74f";
+ context.beginPath();
+ context.moveTo(frame.pivot.x, 0);
+ context.lineTo(frame.pivot.x, frame.frame.h);
+ context.moveTo(0, frame.pivot.y);
+ context.lineTo(frame.frame.w, frame.pivot.y);
+ context.stroke();
+ context.fillStyle = "#f9c74f";
+ context.beginPath();
+ context.arc(frame.pivot.x, frame.pivot.y, 4, 0, Math.PI * 2);
+ context.fill();
+ const origin = frame.combat?.projectile_origin;
+ if (origin) {
+ context.strokeStyle = "#d7c7ff";
+ context.beginPath();
+ context.moveTo(origin.x - 12, origin.y);
+ context.lineTo(origin.x + 12, origin.y);
+ context.moveTo(origin.x, origin.y - 12);
+ context.lineTo(origin.x, origin.y + 12);
+ context.stroke();
+ }
+ context.restore();
+}
+
+function strokeRect(
+ context: CanvasRenderingContext2D,
+ rect: SpriteRect | undefined,
+ strokeStyle: string,
+ dash: number[],
+) {
+ if (!rect) {
+ return;
+ }
+ context.setLineDash(dash);
+ context.strokeStyle = strokeStyle;
+ context.strokeRect(rect.x, rect.y, rect.w, rect.h);
+}
+
+function fillStrokeRect(
+ context: CanvasRenderingContext2D,
+ rect: SpriteRect,
+ fillStyle: string,
+ strokeStyle: string,
+) {
+ context.fillStyle = fillStyle;
+ context.strokeStyle = strokeStyle;
+ context.fillRect(rect.x, rect.y, rect.w, rect.h);
+ context.strokeRect(rect.x, rect.y, rect.w, rect.h);
+}
+
+function errorMessage(error: unknown): string {
+ if (typeof error === "object" && error && "message" in error) {
+ return String((error as { message: unknown }).message);
+ }
+ return String(error);
+}
+
+function cursorForIntent(intent: DragIntent | null, editMode: EditMode): string {
+ if (intent?.action === "resize") {
+ const cursors: Record = {
+ n: "ns-resize",
+ s: "ns-resize",
+ e: "ew-resize",
+ w: "ew-resize",
+ ne: "nesw-resize",
+ sw: "nesw-resize",
+ nw: "nwse-resize",
+ se: "nwse-resize",
+ };
+ return cursors[intent.handle] ?? "grab";
+ }
+ if (intent?.action === "move") {
+ return "grab";
+ }
+ if (editMode === "inspect") {
+ return "default";
+ }
+ return "crosshair";
+}
+
+function dialogPath(value: string | string[] | null): string | null {
+ if (!value) {
+ return null;
+ }
+ return Array.isArray(value) ? value[0] ?? null : value;
+}
+
+export default App;
diff --git a/tools/sprite-studio/src/HelpCenter.tsx b/tools/sprite-studio/src/HelpCenter.tsx
new file mode 100644
index 0000000..4e285b2
--- /dev/null
+++ b/tools/sprite-studio/src/HelpCenter.tsx
@@ -0,0 +1,382 @@
+type HelpCenterProps = {
+ open: boolean;
+ onClose: () => void;
+};
+
+export function HelpCenter({ open, onClose }: HelpCenterProps) {
+ if (!open) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ Como o Studio funciona
+
+ O Studio não edita o PNG do atlas. Ele abre um arquivo
+ *.sprite.json, lê o PNG apontado por
+ image e salva dados que o runtime do jogo consome
+ depois: escala, pivot, duração de frames, hurtboxes, hitboxes e
+ origem de projétil.
+
+
+
+ Fonte de verdade: quando você salva o manifesto
+ real, o próximo carregamento do jogo, Combat Lab ou Sprite Viewer
+ usa esses valores. Não precisa recompilar para trocar JSON ou PNG,
+ mas precisa reiniciar/recarregar a cena que já estava aberta.
+
+
+
+
+ Casos de uso comuns
+
+
+
+
+
+
+
+
+
+ O que cada caixa significa
+
+ Em jogo de luta, o sprite é a imagem. O gameplay usa retângulos
+ invisíveis. Eles não precisam copiar o desenho perfeitamente; eles
+ precisam parecer justos e legíveis para quem joga.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exemplo: hitbox de golpe
+
+ Se você carrega um manifesto, escolhe o frame ativo de um chute e
+ ajusta a caixa vermelha para cobrir só o pé, isso significa que o
+ golpe só acerta quando essa área tocar a hurtbox do oponente. A
+ perna desenhada pode passar um pouco da caixa, mas a parte que
+ causa dano precisa bater com o que o jogador entende como impacto.
+
+
+
+
+ Caixa vermelha longe do pé: o golpe acerta antes de encostar.
+
+
+ Caixa vermelha curta demais: o pé atravessa o inimigo sem hit.
+
+
+ Hurtbox verde no braço/perna esticada cria risco de punição.
+
+
+
+
+
+ Exemplo: projétil
+
+ No fluxo atual, o Studio edita a origem do projétil no frame do
+ personagem. A caixa ofensiva do projétil não está no
+ *.sprite.json; ela vem de
+ ProjectileSpec no código Rust, com largura, altura,
+ dano, velocidade e stun. Por isso você pode ver uma cruz roxa no
+ frame special, mas não uma hitbox vermelha no
+ projectile.
+
+
+
+ Se você mover a origem roxa para a mão, o projétil nasce mais
+ alinhado com a animação. Se quiser mudar o tamanho da caixa que dá
+ dano no projétil, isso ainda é tuning de ProjectileSpec
+ e não edição visual do manifesto.
+
+
+
+
+ Por que uma caixa não aparece?
+
+ Nem todo frame já tem metadata própria. Isso não significa
+ necessariamente erro de carregamento.
+
+
+
+ Se o frame não tem frames[].combat.hitboxes, o
+ Studio não desenha hitbox vermelha para ele.
+
+
+ Se o frame não tem frames[].combat.hurtboxes, o
+ jogo usa a hurtbox base gerada pelo corpo físico do personagem.
+
+
+ Se o frame tem só projectile_origin, ele serve para
+ posicionar o disparo, não para definir a caixa de dano do
+ projétil.
+
+
+ Nos placeholders atuais, Rust já tem algumas hitboxes de
+ soco/chute no manifesto; Duke, Go e C ainda dependem mais de
+ fallback runtime.
+
+
+
+
+
+ Salvar e ver no jogo
+
+ Save JSON escreve o manifesto aberto. Save Metrics escreve o
+ arquivo de corpo físico. Esses dois arquivos têm efeitos
+ diferentes no runtime.
+
+
+
+
+
+
+
+
+
+
+
+ Comandos principais
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function WorkflowCard({
+ title,
+ steps,
+}: {
+ title: string;
+ steps: string[];
+}) {
+ return (
+
+
{title}
+
+ {steps.map((step) => (
+ {step}
+ ))}
+
+
+ );
+}
+
+function ConceptCard({ name, text }: { name: string; text: string }) {
+ return (
+
+ {name}
+ {text}
+
+ );
+}
+
+function Shortcut({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function DataFlowDiagram() {
+ return (
+
+
+
+
+
+
+
+
+
+ atlas PNG
+ imagem
+ *.sprite.json
+ dados
+ Sprite Studio
+ edita JSON
+ valida/exporta
+ jogo
+ runtime
+
+ );
+}
+
+function BoxDictionaryDiagram() {
+ return (
+
+
+
+
+
+
+
+
+
+ sprite
+ hitbox
+ hurtbox
+ pushbox
+ pivot
+ origem
+
+ );
+}
+
+function StrikeExampleDiagram() {
+ return (
+
+
+
+
+
+
+
+ ruim: fantasma
+
+
+
+
+
+ bom: cobre impacto
+
+
+ );
+}
+
+function ProjectileExampleDiagram() {
+ return (
+
+
+
+
+
+
+
+
+ origem no frame
+ caixa runtime
+ ProjectileSpec
+
+ );
+}
+
+function SaveFlowDiagram() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Save
+ no Studio
+ *.sprite.json
+ visual/frame combat
+ body metrics
+ pushbox/base
+ recarregar jogo
+ ou laboratório
+
+ );
+}
diff --git a/tools/sprite-studio/src/MenuBar.tsx b/tools/sprite-studio/src/MenuBar.tsx
new file mode 100644
index 0000000..b119797
--- /dev/null
+++ b/tools/sprite-studio/src/MenuBar.tsx
@@ -0,0 +1,248 @@
+import { useState } from "react";
+
+type MenuBarProps = {
+ autosaveEnabled: boolean;
+ canRedo: boolean;
+ canSave: boolean;
+ canUndo: boolean;
+ leftPanelOpen: boolean;
+ rightPanelOpen: boolean;
+ timelineOpen: boolean;
+ onExportPng: () => void;
+ onExportReview: () => void;
+ onHelp: () => void;
+ onOpen: () => void;
+ onRedo: () => void;
+ onReload: () => void;
+ onRunValidation: () => void;
+ onSave: () => void;
+ onToggleAutosave: () => void;
+ onToggleLeftPanel: () => void;
+ onToggleRightPanel: () => void;
+ onToggleTimeline: () => void;
+ onUndo: () => void;
+};
+
+type MenuKey = "file" | "edit" | "view" | "help";
+
+export function MenuBar({
+ autosaveEnabled,
+ canRedo,
+ canSave,
+ canUndo,
+ leftPanelOpen,
+ rightPanelOpen,
+ timelineOpen,
+ onExportPng,
+ onExportReview,
+ onHelp,
+ onOpen,
+ onRedo,
+ onReload,
+ onRunValidation,
+ onSave,
+ onToggleAutosave,
+ onToggleLeftPanel,
+ onToggleRightPanel,
+ onToggleTimeline,
+ onUndo,
+}: MenuBarProps) {
+ const [openMenu, setOpenMenu] = useState(null);
+
+ function runMenuAction(action: () => void) {
+ action();
+ setOpenMenu(null);
+ }
+
+ return (
+ setOpenMenu(null)}
+ >
+
+
+
+
+
+
+ setOpenMenu("file")}
+ >
+
setOpenMenu(openMenu === "file" ? null : "file")}
+ >
+ File
+
+ {openMenu === "file" && (
+
+ runMenuAction(onOpen)}
+ >
+ Open Manifest...
+
+ runMenuAction(onReload)}
+ >
+ Reload
+
+ runMenuAction(onSave)}
+ >
+ Save JSON
+ Ctrl+S
+
+ runMenuAction(onExportPng)}
+ >
+ Export PNG Review
+
+ runMenuAction(onExportReview)}
+ >
+ Export Review JSON
+
+ runMenuAction(onRunValidation)}
+ >
+ Run Runtime Validation
+
+
+ )}
+
+
+ setOpenMenu("edit")}
+ >
+
setOpenMenu(openMenu === "edit" ? null : "edit")}
+ >
+ Edit
+
+ {openMenu === "edit" && (
+
+ runMenuAction(onUndo)}
+ >
+ Undo
+ Ctrl+Z
+
+ runMenuAction(onRedo)}
+ >
+ Redo
+ Ctrl+Y
+
+ runMenuAction(onToggleAutosave)}
+ >
+ {autosaveEnabled ? "Disable Autosave" : "Enable Autosave"}
+
+
+ )}
+
+
+ setOpenMenu("view")}
+ >
+
setOpenMenu(openMenu === "view" ? null : "view")}
+ >
+ View
+
+ {openMenu === "view" && (
+
+ runMenuAction(onToggleLeftPanel)}
+ >
+ {leftPanelOpen ? "Hide" : "Show"} Browser Panel
+
+ runMenuAction(onToggleRightPanel)}
+ >
+ {rightPanelOpen ? "Hide" : "Show"} Inspector
+
+ runMenuAction(onToggleTimeline)}
+ >
+ {timelineOpen ? "Hide" : "Show"} Timeline
+
+
+ )}
+
+
+ setOpenMenu("help")}
+ >
+
setOpenMenu(openMenu === "help" ? null : "help")}
+ >
+ Help
+
+ {openMenu === "help" && (
+
+ runMenuAction(onHelp)}
+ >
+ Sprite Studio Guide
+
+
+ )}
+
+
+ );
+}
diff --git a/tools/sprite-studio/src/Timeline.tsx b/tools/sprite-studio/src/Timeline.tsx
new file mode 100644
index 0000000..068ddae
--- /dev/null
+++ b/tools/sprite-studio/src/Timeline.tsx
@@ -0,0 +1,130 @@
+import type { SpriteFrame } from "./types";
+
+type TimelineFrame = SpriteFrame & {
+ selected: boolean;
+};
+
+type TimelineProps = {
+ clipName: string | null;
+ frameIndex: number;
+ frames: TimelineFrame[];
+ onNext: () => void;
+ onPrevious: () => void;
+ onSelect: (frameName: string) => void;
+};
+
+export function Timeline({
+ clipName,
+ frameIndex,
+ frames,
+ onNext,
+ onPrevious,
+ onSelect,
+}: TimelineProps) {
+ const totalDuration = frames.reduce(
+ (duration, frame) => duration + frame.duration_ms,
+ 0,
+ );
+ const frameWidths = frames.map((frame) =>
+ Math.max(56, Math.min(164, frame.duration_ms * 0.78)),
+ );
+ const columns = frames
+ .map((_, index) => `${frameWidths[index]}px`)
+ .join(" ");
+ const selectedIndex = frames.findIndex((frame) => frame.selected);
+ const playheadX =
+ selectedIndex >= 0
+ ? frameWidths
+ .slice(0, selectedIndex)
+ .reduce((offset, width) => offset + width, 0) +
+ frameWidths[selectedIndex] / 2
+ : 0;
+ const stripWidth = frameWidths.reduce((total, width) => total + width, 12);
+
+ return (
+
+
+
+ {clipName ?? "No clip"}
+
+ {frames.length ? frameIndex + 1 : 0} / {frames.length} frames ·{" "}
+ {totalDuration} ms
+
+
+
+
+ |<
+ Previous frame
+
+
+ {frames.length ? frameIndex + 1 : 0}
+
+
+ >|
+ Next frame
+
+
+
+
+
+ {frames.length > 0 && (
+
+ )}
+
+ {frames.map((frame, index) => (
+
+
+ {index + 1}
+
+ ))}
+
+
+ {frames.map((frame, index) => (
+ onSelect(frame.name)}
+ >
+ F{index + 1}
+ {frame.name}
+
+ {frame.duration_ms} ms
+
+
+ {(frame.combat?.hurtboxes?.length ?? 0) > 0 && (
+
+ )}
+ {(frame.combat?.hitboxes?.length ?? 0) > 0 && (
+
+ )}
+ {frame.combat?.projectile_origin && (
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/tools/sprite-studio/src/geometry.ts b/tools/sprite-studio/src/geometry.ts
new file mode 100644
index 0000000..45a77fb
--- /dev/null
+++ b/tools/sprite-studio/src/geometry.ts
@@ -0,0 +1,151 @@
+import type { PointerEvent } from "react";
+import type {
+ BoxKind,
+ DragIntent,
+ ResizeHandle,
+ SelectionTarget,
+ SpriteFrame,
+ SpritePoint,
+ SpriteRect,
+} from "./types";
+
+const POINT_RADIUS = 8;
+const HANDLE_RADIUS = 7;
+
+export function localPointFromEvent(
+ event: PointerEvent,
+ zoom: number,
+): SpritePoint {
+ const rect = event.currentTarget.getBoundingClientRect();
+ return {
+ x: (event.clientX - rect.left) / zoom,
+ y: (event.clientY - rect.top) / zoom,
+ };
+}
+
+export function hitTestFrame(frame: SpriteFrame, point: SpritePoint): DragIntent | null {
+ const projectile = frame.combat?.projectile_origin;
+ if (projectile && distance(point, projectile) <= POINT_RADIUS) {
+ return { action: "move", target: { type: "projectile" } };
+ }
+ if (distance(point, frame.pivot) <= POINT_RADIUS) {
+ return { action: "move", target: { type: "pivot" } };
+ }
+
+ const hitboxIntent = hitTestBoxes(frame.combat?.hitboxes ?? [], "hitboxes", point);
+ if (hitboxIntent) {
+ return hitboxIntent;
+ }
+ return hitTestBoxes(frame.combat?.hurtboxes ?? [], "hurtboxes", point);
+}
+
+export function targetKey(target: SelectionTarget | null): string {
+ if (!target) {
+ return "";
+ }
+ if (target.type === "box") {
+ return `${target.kind}:${target.index}`;
+ }
+ return target.type;
+}
+
+export function clampPoint(point: SpritePoint, frame: SpriteFrame): SpritePoint {
+ return {
+ x: clamp(Math.round(point.x), 0, frame.frame.w),
+ y: clamp(Math.round(point.y), 0, frame.frame.h),
+ };
+}
+
+export function moveRect(rect: SpriteRect, delta: SpritePoint, frame: SpriteFrame): SpriteRect {
+ return {
+ ...rect,
+ x: clamp(Math.round(rect.x + delta.x), 0, frame.frame.w - rect.w),
+ y: clamp(Math.round(rect.y + delta.y), 0, frame.frame.h - rect.h),
+ };
+}
+
+export function resizeRect(
+ rect: SpriteRect,
+ handle: ResizeHandle,
+ delta: SpritePoint,
+ frame: SpriteFrame,
+): SpriteRect {
+ let left = rect.x;
+ let top = rect.y;
+ let right = rect.x + rect.w;
+ let bottom = rect.y + rect.h;
+
+ if (handle.includes("w")) {
+ left += delta.x;
+ }
+ if (handle.includes("e")) {
+ right += delta.x;
+ }
+ if (handle.includes("n")) {
+ top += delta.y;
+ }
+ if (handle.includes("s")) {
+ bottom += delta.y;
+ }
+
+ left = clamp(Math.round(left), 0, frame.frame.w - 1);
+ top = clamp(Math.round(top), 0, frame.frame.h - 1);
+ right = clamp(Math.round(right), left + 1, frame.frame.w);
+ bottom = clamp(Math.round(bottom), top + 1, frame.frame.h);
+
+ return {
+ ...rect,
+ x: left,
+ y: top,
+ w: right - left,
+ h: bottom - top,
+ };
+}
+
+export function handlePoints(rect: SpriteRect): Array<{ handle: ResizeHandle; point: SpritePoint }> {
+ const midX = rect.x + rect.w / 2;
+ const midY = rect.y + rect.h / 2;
+ const right = rect.x + rect.w;
+ const bottom = rect.y + rect.h;
+ return [
+ { handle: "nw", point: { x: rect.x, y: rect.y } },
+ { handle: "n", point: { x: midX, y: rect.y } },
+ { handle: "ne", point: { x: right, y: rect.y } },
+ { handle: "e", point: { x: right, y: midY } },
+ { handle: "se", point: { x: right, y: bottom } },
+ { handle: "s", point: { x: midX, y: bottom } },
+ { handle: "sw", point: { x: rect.x, y: bottom } },
+ { handle: "w", point: { x: rect.x, y: midY } },
+ ];
+}
+
+function hitTestBoxes(
+ boxes: SpriteRect[],
+ kind: BoxKind,
+ point: SpritePoint,
+): DragIntent | null {
+ for (let index = boxes.length - 1; index >= 0; index -= 1) {
+ const box = boxes[index];
+ for (const handle of handlePoints(box)) {
+ if (distance(point, handle.point) <= HANDLE_RADIUS) {
+ return {
+ action: "resize",
+ target: { type: "box", kind, index },
+ handle: handle.handle,
+ };
+ }
+ }
+ if (point.x >= box.x && point.y >= box.y && point.x <= box.x + box.w && point.y <= box.y + box.h) {
+ return { action: "move", target: { type: "box", kind, index } };
+ }
+ }
+ return null;
+}
+
+function distance(a: SpritePoint, b: SpritePoint): number {
+ return Math.hypot(a.x - b.x, a.y - b.y);
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/tools/sprite-studio/src/main.tsx b/tools/sprite-studio/src/main.tsx
new file mode 100644
index 0000000..2be325e
--- /dev/null
+++ b/tools/sprite-studio/src/main.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+ ,
+);
diff --git a/tools/sprite-studio/src/manifest.ts b/tools/sprite-studio/src/manifest.ts
new file mode 100644
index 0000000..b621952
--- /dev/null
+++ b/tools/sprite-studio/src/manifest.ts
@@ -0,0 +1,107 @@
+import type { EditMode, SpriteFrame, SpriteManifest, SpriteRect } from "./types";
+
+export const DEFAULT_MANIFEST_PATH =
+ "assets/placeholder/rust-fighter.sprite.json";
+
+export const BODY_METRICS_PATH = "assets/tuning/character-body-metrics.json";
+
+export const PRESET_MANIFESTS = [
+ ["Rust fighter", "assets/placeholder/rust-fighter.sprite.json"],
+ ["Rust intro", "assets/placeholder/rust-start.sprite.json"],
+ ["Duke fighter", "assets/placeholder/duke-fighter.sprite.json"],
+ ["Duke intro", "assets/placeholder/duke-start.sprite.json"],
+ ["Go fighter", "assets/placeholder/go-fighter.sprite.json"],
+ ["Go intro", "assets/placeholder/go-start.sprite.json"],
+ ["C fighter", "assets/placeholder/c-fighter.sprite.json"],
+ ["C intro", "assets/placeholder/c-start.sprite.json"],
+] as const;
+
+export const EDIT_MODES: Array<{ id: EditMode; label: string }> = [
+ { id: "inspect", label: "Inspect" },
+ { id: "pivot", label: "Pivot" },
+ { id: "projectile", label: "Projectile" },
+ { id: "hurtbox", label: "Hurtbox" },
+ { id: "hitbox", label: "Hitbox" },
+];
+
+export function frameByName(
+ manifest: SpriteManifest | null,
+ frameName: string | null,
+): SpriteFrame | null {
+ if (!manifest || !frameName) {
+ return null;
+ }
+ return manifest.frames.find((frame) => frame.name === frameName) ?? null;
+}
+
+export function clipFrameNames(
+ manifest: SpriteManifest | null,
+ clipName: string | null,
+): string[] {
+ if (!manifest || !clipName) {
+ return [];
+ }
+ const clip = manifest.clips.find((entry) => entry.name === clipName);
+ return clip?.frames ?? [];
+}
+
+export function clampRect(rect: SpriteRect, frame: SpriteFrame): SpriteRect {
+ const x = clamp(Math.round(rect.x), 0, frame.frame.w - 1);
+ const y = clamp(Math.round(rect.y), 0, frame.frame.h - 1);
+ const w = clamp(Math.round(rect.w), 1, frame.frame.w - x);
+ const h = clamp(Math.round(rect.h), 1, frame.frame.h - y);
+ return { ...rect, x, y, w, h };
+}
+
+export function defaultBox(
+ x: number,
+ y: number,
+ frame: SpriteFrame,
+ kind: "hurtbox" | "hitbox",
+): SpriteRect {
+ const size = kind === "hurtbox" ? { w: 84, h: 132 } : { w: 72, h: 42 };
+ return clampRect(
+ {
+ x: Math.round(x - size.w / 2),
+ y: Math.round(y - size.h / 2),
+ w: size.w,
+ h: size.h,
+ label: kind === "hurtbox" ? "body" : "strike",
+ },
+ frame,
+ );
+}
+
+export function cloneManifest(manifest: SpriteManifest): SpriteManifest {
+ return JSON.parse(JSON.stringify(manifest)) as SpriteManifest;
+}
+
+export function inferCharacterId(path: string): string {
+ const normalized = path.toLowerCase();
+ if (normalized.includes("duke") || normalized.includes("java")) {
+ return "duke";
+ }
+ if (normalized.includes("/go-") || normalized.includes("go-")) {
+ return "go";
+ }
+ if (normalized.includes("/c-") || normalized.includes("c-fighter")) {
+ return "c";
+ }
+ return "rust";
+}
+
+export function toNumber(value: string, fallback = 0): number {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+export function formatCoordinate(value: number | null): string {
+ if (value === null || !Number.isFinite(value)) {
+ return "-";
+ }
+ return value.toFixed(1);
+}
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
diff --git a/tools/sprite-studio/src/types.ts b/tools/sprite-studio/src/types.ts
new file mode 100644
index 0000000..0fe59ce
--- /dev/null
+++ b/tools/sprite-studio/src/types.ts
@@ -0,0 +1,128 @@
+export type SpritePoint = {
+ x: number;
+ y: number;
+};
+
+export type SpriteRect = {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ label?: string;
+};
+
+export type FrameCombat = {
+ hurtboxes?: SpriteRect[];
+ hitboxes?: SpriteRect[];
+ projectile_origin?: SpritePoint;
+};
+
+export type SpriteFrame = {
+ name: string;
+ clip: string;
+ duration_ms: number;
+ pivot: SpritePoint;
+ frame: SpriteRect;
+ trimmed_bounds?: SpriteRect;
+ source_crop?: SpriteRect;
+ combat?: FrameCombat;
+};
+
+export type SpriteClip = {
+ name: string;
+ frames: string[];
+ loop: boolean;
+};
+
+export type SpriteManifest = {
+ schema: string;
+ image: string;
+ source?: string;
+ cell: {
+ w: number;
+ h: number;
+ };
+ default_pivot: SpritePoint;
+ scale?: number | null;
+ notes?: string[];
+ frames: SpriteFrame[];
+ clips: SpriteClip[];
+};
+
+export type SpriteDocument = {
+ repo_root: string;
+ manifest_path: string;
+ atlas_path: string;
+ manifest_json: string;
+ atlas_data_url: string;
+};
+
+export type SaveResult = {
+ path: string;
+ bytes_written: number;
+ backup_path?: string | null;
+};
+
+export type RuntimeValidationResult = {
+ success: boolean;
+ command: string;
+ stdout: string;
+ stderr: string;
+};
+
+export type TextDocument = {
+ path: string;
+ contents: string;
+};
+
+export type CharacterBodyMetrics = {
+ width: number;
+ standing_height: number;
+ crouch_height: number;
+};
+
+export type BodyMetricsEntry = {
+ id: string;
+ body: CharacterBodyMetrics;
+};
+
+export type BodyMetricsCatalog = {
+ schema: string;
+ characters: BodyMetricsEntry[];
+};
+
+export type EditMode = "inspect" | "pivot" | "projectile" | "hurtbox" | "hitbox";
+
+export type BoxKind = "hurtboxes" | "hitboxes";
+
+export type SelectionTarget =
+ | { type: "pivot" }
+ | { type: "projectile" }
+ | { type: "box"; kind: BoxKind; index: number };
+
+export type ResizeHandle =
+ | "nw"
+ | "n"
+ | "ne"
+ | "e"
+ | "se"
+ | "s"
+ | "sw"
+ | "w";
+
+export type DragIntent =
+ | { action: "move"; target: SelectionTarget }
+ | { action: "resize"; target: Extract; handle: ResizeHandle };
+
+export type DragState = DragIntent & {
+ startMouse: SpritePoint;
+ startPivot?: SpritePoint;
+ startProjectile?: SpritePoint;
+ startRect?: SpriteRect;
+};
+
+export type ValidationIssue = {
+ severity: "error" | "warning";
+ message: string;
+ frameName?: string;
+};
diff --git a/tools/sprite-studio/src/validation.ts b/tools/sprite-studio/src/validation.ts
new file mode 100644
index 0000000..f55611e
--- /dev/null
+++ b/tools/sprite-studio/src/validation.ts
@@ -0,0 +1,172 @@
+import type {
+ SpriteFrame,
+ SpriteManifest,
+ SpritePoint,
+ SpriteRect,
+ ValidationIssue,
+} from "./types";
+
+export function validateManifest(manifest: SpriteManifest | null): ValidationIssue[] {
+ if (!manifest) {
+ return [{ severity: "error", message: "No manifest loaded." }];
+ }
+
+ const issues: ValidationIssue[] = [];
+ if (manifest.schema !== "borrow-fighters.sprite.v1") {
+ issues.push({
+ severity: "warning",
+ message: `Unexpected schema: ${manifest.schema || ""}.`,
+ });
+ }
+ if (!manifest.image) {
+ issues.push({ severity: "error", message: "Manifest field `image` is required." });
+ }
+ if (!isPositive(manifest.cell?.w) || !isPositive(manifest.cell?.h)) {
+ issues.push({ severity: "error", message: "Manifest `cell.w` and `cell.h` must be positive." });
+ }
+ if (manifest.scale !== undefined && manifest.scale !== null && manifest.scale <= 0) {
+ issues.push({ severity: "error", message: "Manifest `scale` must be positive when present." });
+ }
+
+ const frameNames = new Set();
+ for (const frame of manifest.frames) {
+ if (frameNames.has(frame.name)) {
+ issues.push({
+ severity: "error",
+ message: `Duplicate frame name: ${frame.name}.`,
+ frameName: frame.name,
+ });
+ }
+ frameNames.add(frame.name);
+ validateFrame(frame, issues);
+ }
+
+ for (const clip of manifest.clips) {
+ if (!clip.name) {
+ issues.push({ severity: "error", message: "Clip name cannot be empty." });
+ }
+ if (!clip.frames.length) {
+ issues.push({
+ severity: "warning",
+ message: `Clip ${clip.name || ""} has no frames.`,
+ });
+ }
+ for (const frameName of clip.frames) {
+ if (!frameNames.has(frameName)) {
+ issues.push({
+ severity: "error",
+ message: `Clip ${clip.name} references missing frame ${frameName}.`,
+ frameName,
+ });
+ }
+ }
+ }
+
+ return issues;
+}
+
+export function visualScaleSummary(frame: SpriteFrame | null, scale: number) {
+ const bounds = frame?.trimmed_bounds ?? frame?.source_crop;
+ if (!frame || !bounds) {
+ return null;
+ }
+ const width = bounds.w * scale;
+ const height = bounds.h * scale;
+ const heightOk = height >= 185 && height <= 210;
+ const widthOk = width >= 110 && width <= 150;
+ return {
+ width,
+ height,
+ heightOk,
+ widthOk,
+ message:
+ heightOk && widthOk
+ ? "Inside current Rust/C visual target."
+ : "Outside current target; adjust atlas art, pivot or manifest scale before gameplay boxes.",
+ };
+}
+
+function validateFrame(frame: SpriteFrame, issues: ValidationIssue[]) {
+ if (!frame.name) {
+ issues.push({ severity: "error", message: "Frame name cannot be empty." });
+ }
+ if (!isPositive(frame.frame?.w) || !isPositive(frame.frame?.h)) {
+ issues.push({
+ severity: "error",
+ frameName: frame.name,
+ message: "Frame rectangle must have positive width and height.",
+ });
+ }
+ if (!pointInsideFrame(frame.pivot, frame)) {
+ issues.push({
+ severity: "warning",
+ frameName: frame.name,
+ message: "Pivot is outside the frame rectangle.",
+ });
+ }
+ if (!isPositive(frame.duration_ms)) {
+ issues.push({
+ severity: "error",
+ frameName: frame.name,
+ message: "Frame duration must be positive.",
+ });
+ }
+
+ for (const [index, box] of (frame.combat?.hurtboxes ?? []).entries()) {
+ validateBox(box, frame, `hurtbox ${index + 1}`, issues);
+ }
+ for (const [index, box] of (frame.combat?.hitboxes ?? []).entries()) {
+ validateBox(box, frame, `hitbox ${index + 1}`, issues);
+ }
+ const origin = frame.combat?.projectile_origin;
+ if (origin && !pointInsideFrame(origin, frame)) {
+ issues.push({
+ severity: "error",
+ frameName: frame.name,
+ message: "Projectile origin must be inside the frame.",
+ });
+ }
+}
+
+function validateBox(
+ box: SpriteRect,
+ frame: SpriteFrame,
+ label: string,
+ issues: ValidationIssue[],
+) {
+ if (!isPositive(box.w) || !isPositive(box.h)) {
+ issues.push({
+ severity: "error",
+ frameName: frame.name,
+ message: `${label} must have positive width and height.`,
+ });
+ }
+ if (box.x < 0 || box.y < 0 || box.x + box.w > frame.frame.w || box.y + box.h > frame.frame.h) {
+ issues.push({
+ severity: "error",
+ frameName: frame.name,
+ message: `${label} must stay inside the frame.`,
+ });
+ }
+ if (box.label !== undefined && box.label.trim() === "") {
+ issues.push({
+ severity: "error",
+ frameName: frame.name,
+ message: `${label} label cannot be empty when present.`,
+ });
+ }
+}
+
+function pointInsideFrame(point: SpritePoint | undefined, frame: SpriteFrame): boolean {
+ return (
+ !!point &&
+ point.x >= 0 &&
+ point.y >= 0 &&
+ point.x <= frame.frame.w &&
+ point.y <= frame.frame.h
+ );
+}
+
+function isPositive(value: number | undefined): boolean {
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
+}
diff --git a/tools/sprite-studio/src/vite-env.d.ts b/tools/sprite-studio/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/tools/sprite-studio/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tools/sprite-studio/tsconfig.json b/tools/sprite-studio/tsconfig.json
new file mode 100644
index 0000000..a7fc6fb
--- /dev/null
+++ b/tools/sprite-studio/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tools/sprite-studio/tsconfig.node.json b/tools/sprite-studio/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/tools/sprite-studio/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/tools/sprite-studio/vite.config.ts b/tools/sprite-studio/vite.config.ts
new file mode 100644
index 0000000..ddad22a
--- /dev/null
+++ b/tools/sprite-studio/vite.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+// @ts-expect-error process is a nodejs global
+const host = process.env.TAURI_DEV_HOST;
+
+// https://vite.dev/config/
+export default defineConfig(async () => ({
+ plugins: [react()],
+
+ // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
+ //
+ // 1. prevent Vite from obscuring rust errors
+ clearScreen: false,
+ // 2. tauri expects a fixed port, fail if that port is not available
+ server: {
+ port: 1420,
+ strictPort: true,
+ host: host || false,
+ hmr: host
+ ? {
+ protocol: "ws",
+ host,
+ port: 1421,
+ }
+ : undefined,
+ watch: {
+ // 3. tell Vite to ignore watching `src-tauri`
+ ignored: ["**/src-tauri/**"],
+ },
+ },
+}));