Compare commits

...

4 Commits

Author SHA1 Message Date
DaZuo0122
f50243ab96 Fix: Clipping bug 2026-02-14 17:55:35 +08:00
DaZuo0122
f2954ad22b Fix: attempt for clipping bug - not fixed yet 2026-02-14 17:31:55 +08:00
DaZuo0122
1fa7080210 Fix: background splitting bug 2026-02-14 17:08:29 +08:00
DaZuo0122
901bf0ffc3 Add: setting window for tauri - bugs not fixed yet 2026-02-14 13:21:56 +08:00
18 changed files with 1636 additions and 127 deletions

View File

@@ -0,0 +1,35 @@
{
"id": "demogorgon",
"version": "1",
"image": "sprite.png",
"frame_width": 512,
"frame_height": 512,
"animations": [
{
"name": "idle",
"fps": 6,
"frames": [0, 1]
},
{
"name": "active",
"fps": 10,
"frames": [1, 0]
},
{
"name": "success",
"fps": 10,
"frames": [0, 1, 0],
"one_shot": true
},
{
"name": "error",
"fps": 8,
"frames": [1, 0, 1],
"one_shot": true
}
],
"anchor": {
"x": 0.5,
"y": 1.0
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 MiB

View File

@@ -0,0 +1,35 @@
{
"id": "ferris",
"version": "1",
"image": "sprite.png",
"frame_width": 512,
"frame_height": 512,
"animations": [
{
"name": "idle",
"fps": 6,
"frames": [0, 1]
},
{
"name": "active",
"fps": 10,
"frames": [1, 0]
},
{
"name": "success",
"fps": 10,
"frames": [0, 1, 0],
"one_shot": true
},
{
"name": "error",
"fps": 8,
"frames": [1, 0, 1],
"one_shot": true
}
],
"anchor": {
"x": 0.5,
"y": 1.0
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

View File

@@ -10,6 +10,7 @@
"core:window:allow-set-position",
"core:window:allow-inner-size",
"core:window:allow-outer-position",
"core:window:allow-current-monitor",
"core:event:allow-listen",
"core:event:allow-unlisten"
]

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:event:allow-listen","core:event:allow-unlisten"]}}
{"default":{"identifier":"default","description":"Default capability for sprimo-tauri main window runtime APIs.","local":true,"windows":["*"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-inner-size","core:window:allow-outer-position","core:window:allow-current-monitor","core:event:allow-listen","core:event:allow-unlisten"]}}

View File

@@ -1,24 +1,30 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use sprimo_platform::{PlatformAdapter, create_adapter};
use sprimo_protocol::v1::FrontendCommand;
use sprimo_platform::{create_adapter, PlatformAdapter};
use sprimo_protocol::v1::{FrontendState, FrontendStateSnapshot};
use sprimo_runtime_core::{RuntimeCore, RuntimeCoreError};
use sprimo_sprite::{load_manifest, resolve_pack_path, AnimationDefinition};
use sprimo_sprite::{AnimationDefinition, SpritePackManifest, load_manifest, resolve_pack_path};
use std::fs;
use std::sync::Arc;
use tauri::menu::{CheckMenuItem, Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Emitter, Manager, Wry};
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, Wry};
use thiserror::Error;
use tokio::runtime::Runtime;
use tracing::warn;
const APP_NAME: &str = "sprimo";
const DEFAULT_PACK: &str = "default";
const SPRITE_PNG_FILE: &str = "sprite.png";
const SPRITE_GRID_COLUMNS: u32 = 8;
const SPRITE_GRID_ROWS: u32 = 7;
const MAIN_WINDOW_LABEL: &str = "main";
const SETTINGS_WINDOW_LABEL: &str = "settings";
const TRAY_ID: &str = "main";
const MENU_ID_SETTINGS: &str = "settings";
const MENU_ID_TOGGLE_VISIBILITY: &str = "toggle_visibility";
const MENU_ID_TOGGLE_ALWAYS_ON_TOP: &str = "toggle_always_on_top";
const MENU_ID_TOGGLE_DEBUG_OVERLAY: &str = "toggle_debug_overlay";
@@ -50,6 +56,12 @@ struct UiSpritePack {
anchor: UiAnchor,
}
#[derive(Debug, Clone, Copy)]
struct AtlasGeometry {
frame_width: u32,
frame_height: u32,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSnapshot {
state: String,
@@ -58,6 +70,22 @@ struct UiSnapshot {
y: f32,
scale: f32,
active_sprite_pack: String,
visible: bool,
always_on_top: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSettingsSnapshot {
active_sprite_pack: String,
scale: f32,
visible: bool,
always_on_top: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
struct UiSpritePackOption {
id: String,
pack_id_or_path: String,
}
#[derive(Debug, Error)]
@@ -74,6 +102,7 @@ enum AppError {
struct AppState {
runtime_core: Arc<RuntimeCore>,
runtime: Arc<Runtime>,
tray_state: Arc<std::sync::Mutex<Option<TrayMenuState>>>,
}
#[derive(Clone)]
@@ -96,6 +125,7 @@ fn current_state(state: tauri::State<'_, AppState>) -> Result<UiSnapshot, String
#[tauri::command]
fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSpritePack, String> {
let root = sprite_pack_root(state.runtime_core.as_ref())?;
let config = state
.runtime_core
.config()
@@ -103,11 +133,6 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
.map_err(|_| "config lock poisoned".to_string())?
.clone();
let root = std::env::current_dir()
.map_err(|err| err.to_string())?
.join("assets")
.join(config.sprite.sprite_packs_dir);
let selected = config.sprite.selected_pack;
let pack_path = match resolve_pack_path(&root, &selected) {
Ok(path) => path,
@@ -117,6 +142,8 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
let image_path = pack_path.join(&manifest.image);
let image_bytes = std::fs::read(&image_path).map_err(|err| err.to_string())?;
let geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
validate_animation_frames(&manifest)?;
let atlas_data_url = format!(
"data:image/png;base64,{}",
BASE64_STANDARD.encode(image_bytes)
@@ -124,14 +151,10 @@ fn load_active_sprite_pack(state: tauri::State<'_, AppState>) -> Result<UiSprite
Ok(UiSpritePack {
id: manifest.id,
frame_width: manifest.frame_width,
frame_height: manifest.frame_height,
frame_width: geometry.frame_width,
frame_height: geometry.frame_height,
atlas_data_url,
animations: manifest
.animations
.into_iter()
.map(to_ui_clip)
.collect(),
animations: manifest.animations.into_iter().map(to_ui_clip).collect(),
anchor: UiAnchor {
x: manifest.anchor.x,
y: manifest.anchor.y,
@@ -147,6 +170,139 @@ fn debug_overlay_visible(state: tauri::State<'_, AppState>) -> Result<bool, Stri
.map_err(|err| err.to_string())
}
#[tauri::command]
fn settings_snapshot(state: tauri::State<'_, AppState>) -> Result<UiSettingsSnapshot, String> {
let snapshot = state
.runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.clone();
Ok(UiSettingsSnapshot {
active_sprite_pack: snapshot.active_sprite_pack,
scale: snapshot.scale,
visible: snapshot.flags.visible,
always_on_top: snapshot.flags.always_on_top,
})
}
#[tauri::command]
fn list_sprite_packs(state: tauri::State<'_, AppState>) -> Result<Vec<UiSpritePackOption>, String> {
let root = sprite_pack_root(state.runtime_core.as_ref())?;
let mut packs = Vec::new();
let entries = fs::read_dir(root).map_err(|err| err.to_string())?;
for entry in entries {
let entry = entry.map_err(|err| err.to_string())?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if load_manifest(&path).is_err() {
continue;
}
let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
packs.push(UiSpritePackOption {
id: dir_name.to_string(),
pack_id_or_path: dir_name.to_string(),
});
}
packs.sort_by(|a, b| a.id.cmp(&b.id));
Ok(packs)
}
#[tauri::command]
fn set_sprite_pack(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
pack_id_or_path: String,
) -> Result<UiSnapshot, String> {
let root = sprite_pack_root(state.runtime_core.as_ref())?;
let pack_path = resolve_pack_path(&root, &pack_id_or_path).map_err(|err| err.to_string())?;
let manifest = load_manifest(&pack_path).map_err(|err| err.to_string())?;
let image_path = pack_path.join(&manifest.image);
let image_bytes = fs::read(&image_path).map_err(|err| err.to_string())?;
let _geometry = atlas_geometry_for_manifest(&manifest, &image_bytes)?;
validate_animation_frames(&manifest)?;
state
.runtime_core
.apply_command(&FrontendCommand::SetSpritePack { pack_id_or_path })
.map_err(|err| err.to_string())?;
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_scale(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
scale: f32,
) -> Result<UiSnapshot, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("scale must be a positive finite number".to_string());
}
state
.runtime_core
.apply_command(&FrontendCommand::SetTransform {
x: None,
y: None,
anchor: None,
scale: Some(scale),
opacity: None,
})
.map_err(|err| err.to_string())?;
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_visibility(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
visible: bool,
) -> Result<UiSnapshot, String> {
state
.runtime_core
.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: None,
visible: Some(visible),
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
if visible {
window.show().map_err(|err| err.to_string())?;
window.set_focus().map_err(|err| err.to_string())?;
} else {
window.hide().map_err(|err| err.to_string())?;
}
}
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_always_on_top(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
always_on_top: bool,
) -> Result<UiSnapshot, String> {
state
.runtime_core
.apply_command(&FrontendCommand::SetFlags {
click_through: None,
always_on_top: Some(always_on_top),
visible: None,
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
window
.set_always_on_top(always_on_top)
.map_err(|err| err.to_string())?;
}
emit_ui_refresh(state.inner(), &app_handle)
}
#[tauri::command]
fn set_debug_overlay_visible(
app_handle: tauri::AppHandle,
@@ -158,6 +314,11 @@ fn set_debug_overlay_visible(
.set_frontend_debug_overlay_visible(visible)
.map_err(|err| err.to_string())?;
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), &app_handle);
if let Ok(guard) = state.tray_state.lock() {
if let Some(tray_state) = guard.as_ref() {
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
}
}
Ok(visible)
}
@@ -176,6 +337,7 @@ fn main() -> Result<(), AppError> {
let state = AppState {
runtime_core: Arc::clone(&runtime_core),
runtime: Arc::clone(&runtime),
tray_state: Arc::new(std::sync::Mutex::new(None)),
};
tauri::Builder::default()
@@ -185,15 +347,25 @@ fn main() -> Result<(), AppError> {
current_state,
load_active_sprite_pack,
debug_overlay_visible,
set_debug_overlay_visible
set_debug_overlay_visible,
settings_snapshot,
list_sprite_packs,
set_sprite_pack,
set_scale,
set_visibility,
set_always_on_top
])
.setup(|app| {
let app_state: tauri::State<'_, AppState> = app.state();
let runtime_core = Arc::clone(&app_state.runtime_core);
let runtime = Arc::clone(&app_state.runtime);
let tray_state_holder = Arc::clone(&app_state.tray_state);
let app_handle = app.handle().clone();
let tray_state = setup_tray(&app_handle, &runtime_core)?;
if let Ok(mut guard) = tray_state_holder.lock() {
*guard = Some(tray_state.clone());
}
if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
}
@@ -251,10 +423,8 @@ fn main() -> Result<(), AppError> {
};
if runtime_core.apply_command(&command).is_ok() {
if let Ok(snapshot) = runtime_core.snapshot().read() {
let _ = app_handle.emit(
EVENT_RUNTIME_SNAPSHOT,
to_ui_snapshot(&snapshot),
);
let _ = app_handle
.emit(EVENT_RUNTIME_SNAPSHOT, to_ui_snapshot(&snapshot));
}
let _ = refresh_tray_menu_state(
runtime_core.as_ref(),
@@ -295,9 +465,46 @@ fn to_ui_snapshot(snapshot: &FrontendStateSnapshot) -> UiSnapshot {
y: snapshot.y,
scale: snapshot.scale,
active_sprite_pack: snapshot.active_sprite_pack.clone(),
visible: snapshot.flags.visible,
always_on_top: snapshot.flags.always_on_top,
}
}
fn sprite_pack_root(runtime_core: &RuntimeCore) -> Result<std::path::PathBuf, String> {
let sprite_packs_dir = runtime_core
.config()
.read()
.map_err(|_| "config lock poisoned".to_string())?
.sprite
.sprite_packs_dir
.clone();
let root = std::env::current_dir()
.map_err(|err| err.to_string())?
.join("assets")
.join(sprite_packs_dir);
Ok(root)
}
fn emit_ui_refresh(state: &AppState, app_handle: &AppHandle<Wry>) -> Result<UiSnapshot, String> {
let snapshot = state
.runtime_core
.snapshot()
.read()
.map_err(|_| "snapshot lock poisoned".to_string())?
.clone();
let ui_snapshot = to_ui_snapshot(&snapshot);
app_handle
.emit(EVENT_RUNTIME_SNAPSHOT, ui_snapshot.clone())
.map_err(|err| err.to_string())?;
let _ = emit_debug_overlay_visibility(state.runtime_core.as_ref(), app_handle);
if let Ok(guard) = state.tray_state.lock() {
if let Some(tray_state) = guard.as_ref() {
let _ = refresh_tray_menu_state(state.runtime_core.as_ref(), tray_state);
}
}
Ok(ui_snapshot)
}
fn setup_tray(
app_handle: &AppHandle<Wry>,
runtime_core: &RuntimeCore,
@@ -311,6 +518,7 @@ fn setup_tray(
.frontend_debug_overlay_visible()
.map_err(|err| tauri::Error::AssetNotFound(err.to_string()))?;
let settings = MenuItem::with_id(app_handle, MENU_ID_SETTINGS, "Settings", true, None::<&str>)?;
let toggle_visibility = MenuItem::with_id(
app_handle,
MENU_ID_TOGGLE_VISIBILITY,
@@ -338,6 +546,7 @@ fn setup_tray(
let menu = Menu::with_items(
app_handle,
&[
&settings,
&toggle_visibility,
&toggle_always_on_top,
&toggle_debug_overlay,
@@ -368,6 +577,10 @@ fn handle_menu_event(
menu_id: &str,
) -> Result<(), String> {
match menu_id {
MENU_ID_SETTINGS => {
open_settings_window(app_handle).map_err(|err| err.to_string())?;
return Ok(());
}
MENU_ID_TOGGLE_VISIBILITY => {
let current = runtime_core
.snapshot()
@@ -408,7 +621,9 @@ fn handle_menu_event(
})
.map_err(|err| err.to_string())?;
if let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) {
window.set_always_on_top(next).map_err(|err| err.to_string())?;
window
.set_always_on_top(next)
.map_err(|err| err.to_string())?;
}
}
MENU_ID_TOGGLE_DEBUG_OVERLAY => {
@@ -419,7 +634,8 @@ fn handle_menu_event(
runtime_core
.set_frontend_debug_overlay_visible(next)
.map_err(|err| err.to_string())?;
emit_debug_overlay_visibility(runtime_core, app_handle).map_err(|err| err.to_string())?;
emit_debug_overlay_visibility(runtime_core, app_handle)
.map_err(|err| err.to_string())?;
}
MENU_ID_QUIT => {
persist_current_ui_flags(runtime_core)?;
@@ -436,6 +652,30 @@ fn handle_menu_event(
Ok(())
}
fn open_settings_window(app_handle: &AppHandle<Wry>) -> Result<(), tauri::Error> {
if let Some(window) = app_handle.get_webview_window(SETTINGS_WINDOW_LABEL) {
window.show()?;
window.set_focus()?;
return Ok(());
}
let window = WebviewWindowBuilder::new(
app_handle,
SETTINGS_WINDOW_LABEL,
WebviewUrl::App("index.html".into()),
)
.title("sprimo settings")
.inner_size(420.0, 520.0)
.resizable(true)
.decorations(true)
.transparent(false)
.always_on_top(false)
.visible(true)
.build()?;
window.set_focus()?;
Ok(())
}
fn persist_current_ui_flags(runtime_core: &RuntimeCore) -> Result<(), String> {
let snapshot = runtime_core
.snapshot()
@@ -496,11 +736,7 @@ fn refresh_tray_menu_state(
}
fn visibility_menu_title(visible: bool) -> &'static str {
if visible {
"Hide"
} else {
"Show"
}
if visible { "Hide" } else { "Show" }
}
fn state_name(value: FrontendState) -> &'static str {
@@ -522,3 +758,95 @@ fn to_ui_clip(value: AnimationDefinition) -> UiAnimationClip {
one_shot: value.one_shot.unwrap_or(false),
}
}
fn atlas_geometry_for_manifest(
manifest: &SpritePackManifest,
image_bytes: &[u8],
) -> Result<AtlasGeometry, String> {
let (image_width, image_height) = decode_png_dimensions(image_bytes)?;
if image_width == 0 || image_height == 0 {
return Err("atlas image dimensions must be non-zero".to_string());
}
if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
let frame_width = image_width / SPRITE_GRID_COLUMNS;
let frame_height = image_height / SPRITE_GRID_ROWS;
if frame_width == 0 || frame_height == 0 {
return Err(format!(
"sprite atlas too small for {}x{} grid: {}x{}",
SPRITE_GRID_COLUMNS, SPRITE_GRID_ROWS, image_width, image_height
));
}
return Ok(AtlasGeometry {
frame_width,
frame_height,
});
}
if manifest.frame_width == 0 || manifest.frame_height == 0 {
return Err("manifest frame dimensions must be non-zero".to_string());
}
Ok(AtlasGeometry {
frame_width: manifest.frame_width,
frame_height: manifest.frame_height,
})
}
fn validate_animation_frames(manifest: &SpritePackManifest) -> Result<(), String> {
let total_frames = if manifest.image.eq_ignore_ascii_case(SPRITE_PNG_FILE) {
SPRITE_GRID_COLUMNS
.checked_mul(SPRITE_GRID_ROWS)
.ok_or_else(|| "sprite grid frame count overflow".to_string())?
} else {
0
};
if total_frames == 0 {
return Ok(());
}
for clip in &manifest.animations {
for &index in &clip.frames {
if index >= total_frames {
return Err(format!(
"animation '{}' references frame {} but max frame index is {}",
clip.name,
index,
total_frames.saturating_sub(1)
));
}
}
}
Ok(())
}
fn decode_png_dimensions(image_bytes: &[u8]) -> Result<(u32, u32), String> {
const PNG_SIGNATURE_LEN: usize = 8;
const PNG_IHDR_TOTAL_LEN: usize = 33;
const IHDR_TYPE_OFFSET: usize = 12;
const IHDR_DATA_OFFSET: usize = 16;
const IHDR_WIDTH_OFFSET: usize = 16;
const IHDR_HEIGHT_OFFSET: usize = 20;
if image_bytes.len() < PNG_IHDR_TOTAL_LEN {
return Err("atlas image is too small to be a valid PNG".to_string());
}
let expected_signature: [u8; PNG_SIGNATURE_LEN] = [137, 80, 78, 71, 13, 10, 26, 10];
if image_bytes[..PNG_SIGNATURE_LEN] != expected_signature {
return Err("atlas image must be PNG format".to_string());
}
if &image_bytes[IHDR_TYPE_OFFSET..IHDR_DATA_OFFSET] != b"IHDR" {
return Err("atlas PNG missing IHDR chunk".to_string());
}
let width = u32::from_be_bytes(
image_bytes[IHDR_WIDTH_OFFSET..IHDR_WIDTH_OFFSET + 4]
.try_into()
.map_err(|_| "failed to decode PNG width".to_string())?,
);
let height = u32::from_be_bytes(
image_bytes[IHDR_HEIGHT_OFFSET..IHDR_HEIGHT_OFFSET + 4]
.try_into()
.map_err(|_| "failed to decode PNG height".to_string())?,
);
Ok((width, height))
}

View File

@@ -19,12 +19,12 @@ Date: 2026-02-12
| Random backend API tester | Implemented | `scripts/random_backend_tester.py` with `just random-backend-test` and strict variant |
| QA/documentation workflow | Implemented | `docs/QA_WORKFLOW.md`, issue/evidence templates, and `scripts/qa_validate.py` with `just qa-validate` |
| Shared runtime core | Implemented | `sprimo-runtime-core` now backs both Tauri and Bevy startup, snapshot/config ownership, and API wiring |
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented |
| Tauri alternative frontend | In progress | `sprimo-tauri` now runs runtime-core/API + PixiJS sprite rendering shell; scale auto-fit, pop-out settings window (character/scale/visibility/always-on-top), persisted debug-overlay toggle, and Windows-first tray/menu MVP are implemented |
| Tauri runtime testing workflow | Implemented | `docs/TAURI_RUNTIME_TESTING.md` defines strict workspace testing; packaged mode pending packaging support |
## Next Major Gaps
1. Tray/menu controls are still not implemented.
1. Tauri tray/menu behavior still needs Linux/macOS parity validation beyond Windows-first implementation.
2. Linux/macOS overlay behavior remains best-effort with no-op platform adapter.
3. `/v1/state` diagnostics are minimal; error history is not persisted beyond latest runtime error.
4. Issue 1 runtime after-fix screenshot evidence is still pending before closure.

View File

@@ -125,6 +125,23 @@ Bevy window-management verification workflow: `docs/BEVY_WINDOW_VERIFICATION.md`
9. Verify scale-fit behavior in tauri runtime:
- send `SetTransform.scale` values above `1.0`
- confirm full sprite remains visible and window auto-resizes without top clipping
10. Verify pop-out settings window behavior:
- open via tray `Settings`
- switch character and confirm immediate renderer reload + persistence after restart
- change scale slider and confirm runtime resize + persistence
- toggle `Visible` and `Always on top` and confirm both runtime behavior + persistence
11. Verify packaged tauri reload stability:
- run repeated character switch cycles (`default` <-> `ferris`) and move scale slider each cycle
- ensure no runtime frontend exception is shown (debug overlay/console)
- ensure no visible magenta fringe remains around sprite edges after chroma-key conversion
12. Verify packaged tauri scale anchoring and bounds:
- repeated scale changes resize around window center (no consistent bottom-right drift)
- window remains visible on the current monitor (no off-screen drift)
- no runtime scale-path exception appears (for example monitor lookup API errors)
- no runtime position-arg exceptions appear during scale (e.g. float passed to integer position API)
- at large scale values (>= 1.8), full sprite remains visible without clipping
13. Verify packaged tauri frontend freshness:
- confirm package run reflects latest `frontend/tauri-ui` changes (no stale embedded UI bundle)
### Packaged Mode (Required Once Tauri Packaging Exists)

View File

@@ -64,3 +64,14 @@ Path: `<pack_dir>/manifest.json`
- `rows = image_height / frame_height`
- Image dimensions must be divisible by frame dimensions.
- Every animation frame index must be `< columns * rows`.
## Tauri `sprite.png` Override
For `sprimo-tauri`, when manifest `image` is exactly `sprite.png`:
- runtime uses a fixed grid topology of `8` columns x `7` rows.
- frame size is derived from actual image dimensions:
- `frame_width = image_width / 8`
- `frame_height = image_height / 7`
- manifest `frame_width` and `frame_height` are ignored for this case.
- animation frame indices are validated against the fixed grid frame count (`56`).

View File

@@ -47,14 +47,27 @@ Frontend:
- Tauri backend exposes:
- `current_state` command (structured snapshot DTO)
- `load_active_sprite_pack` command (manifest + atlas as base64 data URL)
- settings commands:
- `settings_snapshot`
- `list_sprite_packs`
- `set_sprite_pack`
- `set_scale`
- `set_visibility`
- `set_always_on_top`
- `debug_overlay_visible` / `set_debug_overlay_visible` commands for persisted debug panel control
- `runtime:snapshot` event after command application.
- React/Vite frontend now renders sprite atlas frames with PixiJS and updates animation/scale
from runtime snapshot events.
- For `sprite.png` packs in tauri runtime, frame size is now derived from atlas dimensions with a
fixed `8x7` grid topology to keep splitting stable across packaged asset resolutions.
- React/Vite frontend now supports two window modes:
- `main`: transparent overlay sprite renderer
- `settings`: pop-out settings window for character and window controls
- Tauri window drag is implemented for undecorated mode:
- left-mouse drag starts native window dragging
- moved position is synced into runtime-core snapshot/config state.
- Windows-first tray/menu MVP is implemented:
- `Settings` (opens/focuses pop-out settings window)
- `Show/Hide`
- `Always on top` toggle
- `Debug overlay` toggle
@@ -66,3 +79,4 @@ Frontend:
1. Extend tray/menu implementation beyond Windows-first MVP and close platform parity gaps.
2. Add frontend parity acceptance tests (Bevy vs Tauri state transitions).
3. Add sprite-pack previews/thumbnails in the settings window character selector.

View File

@@ -100,8 +100,47 @@ An issue touching Tauri runtime behaviors must satisfy all requirements before `
6. Validate Tauri invoke/event behavior:
- `current_state` output parsed successfully.
- `load_active_sprite_pack` returns expected fields.
- `settings_snapshot` returns valid persisted settings payload.
- `list_sprite_packs` returns valid manifest-backed pack options.
- `set_sprite_pack` changes active pack and persists.
- `set_scale` updates scale and persists.
- `set_visibility` updates main window visibility and persists.
- `set_always_on_top` updates top-most behavior and persists.
- `runtime:snapshot` event received on runtime command changes.
- `debug_overlay_visible` and `set_debug_overlay_visible` invoke commands work and persist config.
7. Stress runtime reload stability:
- perform at least 10 cycles of character switch (`default` <-> `ferris`) with scale adjustments
- no frontend runtime exception (including `TypeError`) is allowed
- scaling behavior remains responsive after each pack switch
8. Chroma-key quality check:
- verify no visible magenta background/fringe remains around sprite edges in normal runtime view,
including non-exact gradient magenta atlas backgrounds (for example `ferris`/`demogorgon`)
9. Scale anchor and bounds check:
- repeated scale changes should keep window centered without directional drift
- window must remain within current monitor bounds during scale adjustments
- no runtime error is allowed from monitor lookup during scale operations (e.g. `currentMonitor`
API mismatch)
- verify window resize uses consistent coordinate units (no accumulated drift over 20 scale changes)
- no runtime command/type error from position updates (e.g. `set_position` expects integer coords)
## Settings Window Checklist
1. Open settings from tray `Settings` item.
2. Confirm repeated tray clicks focus existing settings window instead of creating duplicates.
3. Change character in settings and verify:
- active pack changes immediately in main overlay
- selection persists after restart
4. Change scale via slider and verify:
- runtime scale changes immediately
- main overlay auto-fits without clipping
- persisted/runtime scale value reflects the effective fitted scale after monitor/window bounds
- value persists after restart
5. Toggle `Visible` and verify:
- main overlay hide/show behavior
- persisted value survives restart
6. Toggle `Always on top` and verify:
- main window z-order behavior updates
- persisted value survives restart
## Evidence Requirements

View File

@@ -2,14 +2,51 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { LogicalPosition, LogicalSize, getCurrentWindow } from "@tauri-apps/api/window";
import {
PhysicalPosition,
PhysicalSize,
currentMonitor,
getCurrentWindow,
monitorFromPoint
} from "@tauri-apps/api/window";
import { PixiPetRenderer, type UiSpritePack, type UiSnapshot } from "./renderer/pixi_pet";
import "./styles.css";
type UiSettingsSnapshot = {
active_sprite_pack: string;
scale: number;
visible: boolean;
always_on_top: boolean;
};
type UiSpritePackOption = {
id: string;
pack_id_or_path: string;
};
const WINDOW_PADDING = 16;
const WINDOW_WORKAREA_MARGIN = 80;
const MIN_WINDOW_SIZE = 64;
const SIZE_EPSILON = 0.5;
const SCALE_EPSILON = 0.0001;
const SCALE_MIN = 0.5;
const SCALE_MAX = 3.0;
async function invokeSetSpritePack(packIdOrPath: string): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_sprite_pack", { packIdOrPath });
}
async function invokeSetScale(scale: number): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_scale", { scale });
}
async function invokeSetVisibility(visible: boolean): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_visibility", { visible });
}
async function invokeSetAlwaysOnTop(alwaysOnTop: boolean): Promise<UiSnapshot> {
return invoke<UiSnapshot>("set_always_on_top", { alwaysOnTop });
}
function fittedWindowSize(
frameWidth: number,
@@ -17,113 +54,232 @@ function fittedWindowSize(
scale: number
): { width: number; height: number } {
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const width = Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE);
const height = Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE);
const width = Math.round(Math.max(frameWidth * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE));
const height = Math.round(Math.max(frameHeight * safeScale + WINDOW_PADDING, MIN_WINDOW_SIZE));
return { width, height };
}
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<void> {
const window = getCurrentWindow();
const [outerPosition, innerSize] = await Promise.all([
window.outerPosition(),
window.innerSize()
]);
const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale);
const widthChanged = Math.abs(target.width - innerSize.width) > SIZE_EPSILON;
const heightChanged = Math.abs(target.height - innerSize.height) > SIZE_EPSILON;
if (!widthChanged && !heightChanged) {
return;
}
const deltaWidth = target.width - innerSize.width;
const deltaHeight = target.height - innerSize.height;
const targetX = outerPosition.x - deltaWidth / 2;
const targetY = outerPosition.y - deltaHeight;
await window.setSize(new LogicalSize(target.width, target.height));
await window.setPosition(new LogicalPosition(targetX, targetY));
function effectiveScaleForWindowSize(pack: UiSpritePack, width: number, height: number): number {
const availableWidth = Math.max(width - WINDOW_PADDING, MIN_WINDOW_SIZE);
const availableHeight = Math.max(height - WINDOW_PADDING, MIN_WINDOW_SIZE);
const scaleByWidth = availableWidth / Math.max(pack.frame_width, 1);
const scaleByHeight = availableHeight / Math.max(pack.frame_height, 1);
const scale = Math.min(scaleByWidth, scaleByHeight);
return Math.max(SCALE_MIN, Math.min(scale, SCALE_MAX));
}
function App(): JSX.Element {
async function fitWindowForScale(pack: UiSpritePack, scale: number): Promise<number> {
const window = getCurrentWindow();
const [outerPosition, innerSize] = await Promise.all([window.outerPosition(), window.innerSize()]);
const target = fittedWindowSize(pack.frame_width, pack.frame_height, scale);
const centerX = outerPosition.x + innerSize.width / 2;
const centerY = outerPosition.y + innerSize.height / 2;
let targetWidth = target.width;
let targetHeight = target.height;
let targetX = centerX - targetWidth / 2;
let targetY = centerY - targetHeight / 2;
let monitor:
| {
position: { x: number; y: number };
size: { width: number; height: number };
workArea: { position: { x: number; y: number }; size: { width: number; height: number } };
}
| null = null;
try {
monitor = (await monitorFromPoint(centerX, centerY)) ?? (await currentMonitor());
} catch {
monitor = null;
}
if (monitor !== null) {
const widthCap = Math.max(
monitor.workArea.size.width - WINDOW_WORKAREA_MARGIN,
MIN_WINDOW_SIZE
);
const heightCap = Math.max(
monitor.workArea.size.height - WINDOW_WORKAREA_MARGIN,
MIN_WINDOW_SIZE
);
targetWidth = Math.min(targetWidth, widthCap);
targetHeight = Math.min(targetHeight, heightCap);
targetX = centerX - targetWidth / 2;
targetY = centerY - targetHeight / 2;
}
const widthChanged = Math.abs(targetWidth - innerSize.width) > SIZE_EPSILON;
const heightChanged = Math.abs(targetHeight - innerSize.height) > SIZE_EPSILON;
if (widthChanged || heightChanged) {
await window.setSize(new PhysicalSize(targetWidth, targetHeight));
if (monitor !== null) {
const minX = Math.round(monitor.workArea.position.x);
const minY = Math.round(monitor.workArea.position.y);
const maxX = Math.round(
monitor.workArea.position.x + monitor.workArea.size.width - targetWidth
);
const maxY = Math.round(
monitor.workArea.position.y + monitor.workArea.size.height - targetHeight
);
targetX = maxX < minX ? minX : Math.min(Math.max(targetX, minX), maxX);
targetY = maxY < minY ? minY : Math.min(Math.max(targetY, minY), maxY);
}
await window.setPosition(new PhysicalPosition(Math.round(targetX), Math.round(targetY)));
}
return effectiveScaleForWindowSize(pack, targetWidth, targetHeight);
}
function MainOverlayWindow(): JSX.Element {
const [snapshot, setSnapshot] = React.useState<UiSnapshot | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [debugOverlayVisible, setDebugOverlayVisible] = React.useState(false);
const hostRef = React.useRef<HTMLDivElement | null>(null);
const rendererRef = React.useRef<PixiPetRenderer | null>(null);
const scaleFitRef = React.useRef<number | null>(null);
const activePackRef = React.useRef<UiSpritePack | null>(null);
const loadedPackKeyRef = React.useRef<string | null>(null);
const effectiveScaleSyncRef = React.useRef<number | null>(null);
const loadingPackRef = React.useRef(false);
const mountedRef = React.useRef(false);
React.useEffect(() => {
mountedRef.current = true;
let unlisten: null | (() => void) = null;
let mounted = true;
let activePack: UiSpritePack | null = null;
const recreateRenderer = async (
pack: UiSpritePack,
nextSnapshot: UiSnapshot
): Promise<boolean> => {
if (!mountedRef.current || hostRef.current === null) {
return false;
}
const previousRenderer = rendererRef.current;
const nextRenderer = await PixiPetRenderer.create(hostRef.current, pack, nextSnapshot);
rendererRef.current = nextRenderer;
activePackRef.current = pack;
loadedPackKeyRef.current = nextSnapshot.active_sprite_pack;
previousRenderer?.dispose();
return true;
};
const tryFitWindow = async (pack: UiSpritePack, scale: number): Promise<number | null> => {
try {
return await fitWindowForScale(pack, scale);
} catch (err) {
if (mountedRef.current) {
setError(String(err));
}
return null;
}
};
const syncEffectiveScale = async (snapshotScale: number, effectiveScale: number): Promise<void> => {
if (Math.abs(snapshotScale - effectiveScale) < SCALE_EPSILON) {
return;
}
if (
effectiveScaleSyncRef.current !== null &&
Math.abs(effectiveScaleSyncRef.current - effectiveScale) < SCALE_EPSILON
) {
return;
}
effectiveScaleSyncRef.current = effectiveScale;
try {
await invokeSetScale(effectiveScale);
} catch (err) {
if (mountedRef.current) {
setError(String(err));
}
}
};
const processSnapshot = async (value: UiSnapshot): Promise<void> => {
if (!mountedRef.current) {
return;
}
if (
effectiveScaleSyncRef.current !== null &&
Math.abs(effectiveScaleSyncRef.current - value.scale) < SCALE_EPSILON
) {
effectiveScaleSyncRef.current = null;
}
setSnapshot(value);
rendererRef.current?.applySnapshot(value);
const activePack = activePackRef.current;
const needsReload =
activePack === null || loadedPackKeyRef.current !== value.active_sprite_pack;
if (needsReload && !loadingPackRef.current) {
loadingPackRef.current = true;
let reloaded = false;
try {
const pack = await invoke<UiSpritePack>("load_active_sprite_pack");
reloaded = await recreateRenderer(pack, value);
if (reloaded) {
const effectiveScale = await tryFitWindow(pack, value.scale);
if (effectiveScale !== null) {
await syncEffectiveScale(value.scale, effectiveScale);
}
if (mountedRef.current && effectiveScale !== null) {
setError(null);
}
}
} catch (err) {
if (mountedRef.current) {
console.error("reload_pack_failed", err);
setError(String(err));
}
} finally {
loadingPackRef.current = false;
}
}
if (activePackRef.current === null) {
return;
}
const effectiveScale = await tryFitWindow(activePackRef.current, value.scale);
if (effectiveScale !== null) {
await syncEffectiveScale(value.scale, effectiveScale);
}
if (effectiveScale !== null && mountedRef.current) {
setError(null);
}
};
Promise.all([
invoke<UiSpritePack>("load_active_sprite_pack"),
invoke<UiSnapshot>("current_state"),
invoke<boolean>("debug_overlay_visible")
])
.then(async ([pack, initialSnapshot, showDebug]) => {
if (!mounted) {
if (!mountedRef.current) {
return;
}
activePack = pack;
setDebugOverlayVisible(showDebug);
setSnapshot(initialSnapshot);
if (hostRef.current !== null) {
rendererRef.current = await PixiPetRenderer.create(
hostRef.current,
pack,
initialSnapshot
);
}
scaleFitRef.current = initialSnapshot.scale;
await fitWindowForScale(pack, initialSnapshot.scale);
await recreateRenderer(pack, initialSnapshot);
await processSnapshot(initialSnapshot);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
}
const value = event.payload;
setSnapshot(value);
rendererRef.current?.applySnapshot(value);
if (activePack === null) {
return;
}
if (
scaleFitRef.current !== null &&
Math.abs(scaleFitRef.current - value.scale) < SCALE_EPSILON
) {
return;
}
scaleFitRef.current = value.scale;
void fitWindowForScale(activePack, value.scale).catch((err) => {
if (mounted) {
setError(String(err));
}
const unlistenSnapshot = await listen<UiSnapshot>("runtime:snapshot", (event) => {
void processSnapshot(event.payload);
});
});
const unlistenDebug = await listen<boolean>(
"runtime:debug-overlay-visible",
(event) => {
if (!mounted) {
const unlistenDebug = await listen<boolean>("runtime:debug-overlay-visible", (event) => {
if (!mountedRef.current) {
return;
}
setDebugOverlayVisible(Boolean(event.payload));
}
);
const previousUnlisten = unlisten;
});
unlisten = () => {
previousUnlisten();
unlistenSnapshot();
unlistenDebug();
};
})
.catch((err) => {
if (mounted) {
if (mountedRef.current) {
setError(String(err));
}
});
return () => {
mounted = false;
mountedRef.current = false;
if (unlisten !== null) {
unlisten();
}
@@ -168,7 +324,7 @@ function App(): JSX.Element {
}, []);
return (
<main className="app" onMouseDown={onMouseDown}>
<main className="app overlay-app" onMouseDown={onMouseDown}>
<div className="canvas-host" ref={hostRef} />
{error !== null && !debugOverlayVisible ? <p className="error-banner">{error}</p> : null}
{debugOverlayVisible ? (
@@ -200,8 +356,216 @@ function App(): JSX.Element {
);
}
function SettingsWindow(): JSX.Element {
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [pending, setPending] = React.useState(false);
React.useEffect(() => {
let unlisten: null | (() => void) = null;
let mounted = true;
Promise.all([
invoke<UiSettingsSnapshot>("settings_snapshot"),
invoke<UiSpritePackOption[]>("list_sprite_packs"),
invoke<UiSpritePack>("load_active_sprite_pack")
])
.then(async ([snapshot, options, pack]) => {
if (!mounted) {
return;
}
setSettings(snapshot);
setPacks(options);
setActivePack(pack);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
}
const payload = event.payload;
if (payload.active_sprite_pack !== activePack?.id) {
void invoke<UiSpritePack>("load_active_sprite_pack")
.then((nextPack) => {
if (mounted) {
setActivePack(nextPack);
}
})
.catch(() => {
// Keep existing pack metadata if reload fails.
});
}
setSettings((prev) => {
if (prev === null) {
return prev;
}
return {
active_sprite_pack: payload.active_sprite_pack,
scale: payload.scale,
visible: payload.visible,
always_on_top: payload.always_on_top
};
});
});
})
.catch((err) => {
if (mounted) {
setError(String(err));
}
});
return () => {
mounted = false;
if (unlisten !== null) {
unlisten();
}
};
}, [activePack?.id]);
const withPending = React.useCallback(async <T,>(fn: () => Promise<T>): Promise<T | null> => {
setPending(true);
setError(null);
try {
return await fn();
} catch (err) {
setError(String(err));
return null;
} finally {
setPending(false);
}
}, []);
const onPackChange = React.useCallback(
async (event: React.ChangeEvent<HTMLSelectElement>) => {
const value = event.target.value;
const next = await withPending(() => invokeSetSpritePack(value));
if (next === null) {
return;
}
const refreshedPack = await withPending(() => invoke<UiSpritePack>("load_active_sprite_pack"));
if (refreshedPack !== null) {
setActivePack(refreshedPack);
}
setSettings((prev) =>
prev === null
? prev
: {
...prev,
active_sprite_pack: next.active_sprite_pack
}
);
},
[withPending]
);
const onScaleChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(event.target.value);
if (!Number.isFinite(value)) {
return;
}
const next = await withPending(() => invokeSetScale(value));
if (next === null) {
return;
}
setSettings((prev) => (prev === null ? prev : { ...prev, scale: next.scale }));
},
[withPending]
);
const onVisibleChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.checked;
const next = await withPending(() => invokeSetVisibility(value));
if (next === null) {
return;
}
setSettings((prev) => (prev === null ? prev : { ...prev, visible: value }));
},
[withPending]
);
const onAlwaysOnTopChange = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.checked;
const next = await withPending(() => invokeSetAlwaysOnTop(value));
if (next === null) {
return;
}
setSettings((prev) => (prev === null ? prev : { ...prev, always_on_top: value }));
},
[withPending]
);
return (
<main className="settings-root">
<section className="settings-card">
<h1>Settings</h1>
<p className="settings-subtitle">Character and window controls</p>
{error !== null ? <p className="settings-error">{error}</p> : null}
{settings === null ? (
<p>Loading settings...</p>
) : (
<>
<label className="field">
<span>Character</span>
<select
value={settings.active_sprite_pack}
disabled={pending}
onChange={onPackChange}
>
{packs.map((pack) => (
<option key={pack.pack_id_or_path} value={pack.pack_id_or_path}>
{pack.id}
</option>
))}
</select>
</label>
<label className="field">
<span>Scale: {settings.scale.toFixed(2)}x</span>
<input
type="range"
min={SCALE_MIN}
max={SCALE_MAX}
step={0.05}
value={settings.scale}
disabled={pending}
onChange={onScaleChange}
/>
</label>
<label className="toggle">
<input
type="checkbox"
checked={settings.visible}
disabled={pending}
onChange={onVisibleChange}
/>
<span>Visible</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={settings.always_on_top}
disabled={pending}
onChange={onAlwaysOnTopChange}
/>
<span>Always on top</span>
</label>
</>
)}
</section>
</main>
);
}
function AppRoot(): JSX.Element {
const windowLabel = getCurrentWindow().label;
if (windowLabel === "settings") {
return <SettingsWindow />;
}
return <MainOverlayWindow />;
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
<AppRoot />
</React.StrictMode>
);

View File

@@ -28,9 +28,31 @@ export type UiSnapshot = {
y: number;
scale: number;
active_sprite_pack: string;
visible: boolean;
always_on_top: boolean;
};
type AnimationMap = Map<string, UiAnimationClip>;
const KEY_R = 0xff;
const KEY_G = 0x00;
const KEY_B = 0xff;
const FALLBACK_MIN_CONNECTED_RATIO = 0.005;
const CONNECTED_HUE_MIN = 270;
const CONNECTED_HUE_MAX = 350;
const CONNECTED_SAT_MIN = 0.25;
const CONNECTED_VAL_MIN = 0.08;
const FALLBACK_HUE_MIN = 255;
const FALLBACK_HUE_MAX = 355;
const FALLBACK_SAT_MIN = 0.15;
const FALLBACK_VAL_MIN = 0.04;
const STRONG_MAGENTA_RB_MIN = 72;
const STRONG_MAGENTA_DOMINANCE = 24;
const HALO_HUE_MIN = 245;
const HALO_HUE_MAX = 355;
const HALO_SAT_MIN = 0.15;
const HALO_VAL_MIN = 0.04;
const RENDER_FIT_PADDING = 16;
const MIN_RENDER_SCALE = 0.01;
export class PixiPetRenderer {
private app: Application;
@@ -41,6 +63,7 @@ export class PixiPetRenderer {
private frameCursor = 0;
private frameElapsedMs = 0;
private baseTexture: BaseTexture;
private disposed = false;
private constructor(
app: Application,
@@ -67,8 +90,6 @@ export class PixiPetRenderer {
antialias: true,
resizeTo: container
});
container.replaceChildren(app.view as HTMLCanvasElement);
const baseTexture = await PixiPetRenderer.loadBaseTexture(pack.atlas_data_url);
if (baseTexture.width <= 0 || baseTexture.height <= 0) {
throw new Error("Atlas image loaded with invalid dimensions.");
@@ -76,6 +97,7 @@ export class PixiPetRenderer {
const sprite = new Sprite();
sprite.anchor.set(pack.anchor.x, pack.anchor.y);
app.stage.addChild(sprite);
container.replaceChildren(app.view as HTMLCanvasElement);
const renderer = new PixiPetRenderer(app, sprite, pack, baseTexture);
renderer.layoutSprite();
@@ -99,16 +121,222 @@ export class PixiPetRenderer {
ctx.drawImage(image, 0, 0);
const frame = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = frame.data;
const keyR = 0xff;
const keyG = 0x00;
const keyB = 0xff;
const tolerance = 28;
for (let i = 0; i < data.length; i += 4) {
const dr = Math.abs(data[i] - keyR);
const dg = Math.abs(data[i + 1] - keyG);
const db = Math.abs(data[i + 2] - keyB);
if (dr <= tolerance && dg <= tolerance && db <= tolerance) {
data[i + 3] = 0;
const width = canvas.width;
const height = canvas.height;
const pixelCount = width * height;
const isKeyLike = new Uint8Array(pixelCount);
const removedBg = new Uint8Array(pixelCount);
const queue = new Int32Array(pixelCount);
let head = 0;
let tail = 0;
const indexFor = (x: number, y: number): number => y * width + x;
const channelOffset = (index: number): number => index * 4;
const enqueueIfKeyLike = (x: number, y: number): void => {
const idx = indexFor(x, y);
if (isKeyLike[idx] === 1 && removedBg[idx] === 0) {
removedBg[idx] = 1;
queue[tail] = idx;
tail += 1;
}
};
for (let idx = 0; idx < pixelCount; idx += 1) {
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
if (
PixiPetRenderer.isHueInRange(h, CONNECTED_HUE_MIN, CONNECTED_HUE_MAX) &&
s >= CONNECTED_SAT_MIN &&
v >= CONNECTED_VAL_MIN
) {
isKeyLike[idx] = 1;
continue;
}
if (
PixiPetRenderer.isStrongMagentaFamily(
data[offset],
data[offset + 1],
data[offset + 2]
)
) {
isKeyLike[idx] = 1;
}
}
for (let x = 0; x < width; x += 1) {
enqueueIfKeyLike(x, 0);
enqueueIfKeyLike(x, height - 1);
}
for (let y = 1; y < height - 1; y += 1) {
enqueueIfKeyLike(0, y);
enqueueIfKeyLike(width - 1, y);
}
while (head < tail) {
const idx = queue[head];
head += 1;
const x = idx % width;
const y = Math.floor(idx / width);
if (x > 0) {
enqueueIfKeyLike(x - 1, y);
}
if (x + 1 < width) {
enqueueIfKeyLike(x + 1, y);
}
if (y > 0) {
enqueueIfKeyLike(x, y - 1);
}
if (y + 1 < height) {
enqueueIfKeyLike(x, y + 1);
}
}
const connectedRemovedCount = tail;
for (let idx = 0; idx < pixelCount; idx += 1) {
if (removedBg[idx] !== 1) {
continue;
}
const offset = channelOffset(idx);
data[offset + 3] = 0;
}
const needsFallback =
connectedRemovedCount / Math.max(pixelCount, 1) < FALLBACK_MIN_CONNECTED_RATIO;
if (needsFallback) {
for (let idx = 0; idx < pixelCount; idx += 1) {
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
const maxDistanceFromHardKey = PixiPetRenderer.maxColorDistance(
data[offset],
data[offset + 1],
data[offset + 2],
KEY_R,
KEY_G,
KEY_B
);
if (
(PixiPetRenderer.isHueInRange(h, FALLBACK_HUE_MIN, FALLBACK_HUE_MAX) &&
s >= FALLBACK_SAT_MIN &&
v >= FALLBACK_VAL_MIN) ||
PixiPetRenderer.isStrongMagentaFamily(
data[offset],
data[offset + 1],
data[offset + 2]
) ||
maxDistanceFromHardKey <= 96
) {
data[offset + 3] = 0;
}
}
}
// Deterministic last pass: remove any border-connected magenta-family background.
head = 0;
tail = 0;
removedBg.fill(0);
const enqueueIfMagentaBorder = (x: number, y: number): void => {
const idx = indexFor(x, y);
if (removedBg[idx] === 1) {
return;
}
const offset = channelOffset(idx);
if (data[offset + 3] === 0) {
return;
}
if (
!PixiPetRenderer.isStrongMagentaFamily(
data[offset],
data[offset + 1],
data[offset + 2]
)
) {
return;
}
removedBg[idx] = 1;
queue[tail] = idx;
tail += 1;
};
for (let x = 0; x < width; x += 1) {
enqueueIfMagentaBorder(x, 0);
enqueueIfMagentaBorder(x, height - 1);
}
for (let y = 1; y < height - 1; y += 1) {
enqueueIfMagentaBorder(0, y);
enqueueIfMagentaBorder(width - 1, y);
}
while (head < tail) {
const idx = queue[head];
head += 1;
const x = idx % width;
const y = Math.floor(idx / width);
if (x > 0) {
enqueueIfMagentaBorder(x - 1, y);
}
if (x + 1 < width) {
enqueueIfMagentaBorder(x + 1, y);
}
if (y > 0) {
enqueueIfMagentaBorder(x, y - 1);
}
if (y + 1 < height) {
enqueueIfMagentaBorder(x, y + 1);
}
}
for (let idx = 0; idx < pixelCount; idx += 1) {
if (removedBg[idx] !== 1) {
continue;
}
const offset = channelOffset(idx);
data[offset + 3] = 0;
}
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const idx = indexFor(x, y);
if (data[channelOffset(idx) + 3] === 0) {
continue;
}
let touchesBackground = false;
if (x > 0 && data[channelOffset(indexFor(x - 1, y)) + 3] === 0) {
touchesBackground = true;
} else if (x + 1 < width && data[channelOffset(indexFor(x + 1, y)) + 3] === 0) {
touchesBackground = true;
} else if (y > 0 && data[channelOffset(indexFor(x, y - 1)) + 3] === 0) {
touchesBackground = true;
} else if (y + 1 < height && data[channelOffset(indexFor(x, y + 1)) + 3] === 0) {
touchesBackground = true;
}
if (!touchesBackground) {
continue;
}
const offset = channelOffset(idx);
const [h, s, v] = PixiPetRenderer.rgbToHsv(
data[offset],
data[offset + 1],
data[offset + 2]
);
if (
!PixiPetRenderer.isHueInRange(h, HALO_HUE_MIN, HALO_HUE_MAX) ||
s < HALO_SAT_MIN ||
v < HALO_VAL_MIN
) {
continue;
}
data[offset] = Math.round(data[offset] * 0.72);
data[offset + 2] = Math.round(data[offset + 2] * 0.72);
data[offset + 3] = Math.round(data[offset + 3] * 0.86);
}
}
ctx.putImageData(frame, 0, 0);
@@ -121,14 +349,67 @@ export class PixiPetRenderer {
});
}
private static maxColorDistance(
r: number,
g: number,
b: number,
keyR: number,
keyG: number,
keyB: number
): number {
const dr = Math.abs(r - keyR);
const dg = Math.abs(g - keyG);
const db = Math.abs(b - keyB);
return Math.max(dr, dg, db);
}
private static rgbToHsv(r: number, g: number, b: number): [number, number, number] {
const rf = r / 255;
const gf = g / 255;
const bf = b / 255;
const max = Math.max(rf, gf, bf);
const min = Math.min(rf, gf, bf);
const delta = max - min;
let hue = 0;
if (delta > 0) {
if (max === rf) {
hue = 60 * (((gf - bf) / delta) % 6);
} else if (max === gf) {
hue = 60 * ((bf - rf) / delta + 2);
} else {
hue = 60 * ((rf - gf) / delta + 4);
}
}
if (hue < 0) {
hue += 360;
}
const saturation = max === 0 ? 0 : delta / max;
const value = max;
return [hue, saturation, value];
}
private static isHueInRange(hue: number, min: number, max: number): boolean {
if (min <= max) {
return hue >= min && hue <= max;
}
return hue >= min || hue <= max;
}
private static isStrongMagentaFamily(r: number, g: number, b: number): boolean {
const minRb = Math.min(r, b);
return (
r >= STRONG_MAGENTA_RB_MIN &&
b >= STRONG_MAGENTA_RB_MIN &&
g + STRONG_MAGENTA_DOMINANCE <= minRb
);
}
dispose(): void {
this.app.ticker.stop();
this.app.ticker.destroy();
this.sprite.destroy({
children: true,
texture: false,
baseTexture: false
});
if (this.disposed) {
return;
}
this.disposed = true;
this.app.destroy(true, {
children: true,
texture: false,
@@ -137,6 +418,9 @@ export class PixiPetRenderer {
}
applySnapshot(snapshot: UiSnapshot): void {
if (this.disposed) {
return;
}
const nextClip = this.resolveClip(snapshot.current_animation);
if (nextClip.name !== this.currentClip.name) {
this.currentClip = nextClip;
@@ -144,12 +428,14 @@ export class PixiPetRenderer {
this.frameElapsedMs = 0;
this.applyFrameTexture(this.currentClip.frames[0] ?? 0);
}
this.sprite.scale.set(snapshot.scale);
this.layoutSprite();
}
private startTicker(): void {
this.app.ticker.add((ticker) => {
if (this.disposed) {
return;
}
this.layoutSprite();
const frameMs = 1000 / Math.max(this.currentClip.fps, 1);
this.frameElapsedMs += ticker.deltaMS;
@@ -172,6 +458,12 @@ export class PixiPetRenderer {
}
private layoutSprite(): void {
const availableWidth = Math.max(this.app.renderer.width - RENDER_FIT_PADDING, 1);
const availableHeight = Math.max(this.app.renderer.height - RENDER_FIT_PADDING, 1);
const fitScaleX = availableWidth / Math.max(this.pack.frame_width, 1);
const fitScaleY = availableHeight / Math.max(this.pack.frame_height, 1);
const fitScale = Math.max(Math.min(fitScaleX, fitScaleY), MIN_RENDER_SCALE);
this.sprite.scale.set(fitScale);
this.sprite.position.set(this.app.renderer.width / 2, this.app.renderer.height);
}

View File

@@ -73,3 +73,76 @@ dd {
color: #fee2e2;
font-size: 12px;
}
.settings-root {
min-height: 100vh;
display: flex;
align-items: stretch;
justify-content: center;
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
color: #0f172a;
user-select: none;
}
.settings-card {
width: 100%;
max-width: 480px;
margin: 0;
padding: 20px;
display: grid;
gap: 14px;
align-content: start;
}
.settings-card h1 {
margin: 0;
font-size: 24px;
}
.settings-subtitle {
margin: 0;
color: #334155;
font-size: 13px;
}
.settings-error {
margin: 0;
color: #991b1b;
background: #fee2e2;
border: 1px solid #fca5a5;
padding: 8px 10px;
border-radius: 8px;
font-size: 13px;
}
.field {
display: grid;
gap: 8px;
}
.field span {
font-size: 13px;
font-weight: 600;
color: #1e293b;
}
.field select,
.field input[type="range"] {
width: 100%;
}
.field select {
border: 1px solid #94a3b8;
border-radius: 8px;
padding: 8px 10px;
background: #ffffff;
color: #0f172a;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: #1e293b;
}

98
issues/issue3.md Normal file
View File

@@ -0,0 +1,98 @@
## Title
`sprimo-tauri` settings window invoke errors for sprite-pack switch and always-on-top toggle.
## Severity
P1
## Environment
- OS: Windows
- App version/build: `sprimo-tauri` workspace runtime
- Renderer/backend details: Tauri UI settings pop-out window
- Evidence screenshots:
- `issues/screenshots/issue3.png`
- `issues/screenshots/issue3-b.png`
## Summary
When using the settings pop-out window, changing character or toggling always-on-top shows
invoke argument validation errors and the action fails to apply.
## Reproduction Steps
1. Start `sprimo-tauri` and open tray menu `Settings`.
2. Change `Character` selection.
3. Toggle `Always on top`.
4. Observe red error banner in settings window.
## Expected Result
- Character change applies successfully and updates active sprite pack.
- Always-on-top toggle applies successfully and updates main window z-order state.
- No invoke argument error appears.
## Actual Result
- Character change fails with:
- `invalid args 'packIdOrPath' for command 'set_sprite_pack'`
- Always-on-top toggle fails with:
- `invalid args 'alwaysOnTop' for command 'set_always_on_top'`
## Root Cause Analysis
- Frontend invoke payload keys were sent in snake_case (`pack_id_or_path`, `always_on_top`).
- Tauri JS invoke argument mapping for these Rust command parameter names expects camelCase
keys (`packIdOrPath`, `alwaysOnTop`).
- Because the required keys were missing from Tauri's perspective, command handlers were not run.
## Fix Plan
1. Update settings invoke payload keys to Tauri-compatible camelCase names.
2. Add typed helper wrappers for settings invokes to centralize argument naming.
3. Rebuild UI and run Rust compile checks.
4. Perform runtime manual validation and capture after screenshot evidence.
## Implementation Notes
Implemented in `frontend/tauri-ui/src/main.tsx`:
1. Added typed invoke wrappers:
- `invokeSetSpritePack(packIdOrPath)`
- `invokeSetScale(scale)`
- `invokeSetVisibility(visible)`
- `invokeSetAlwaysOnTop(alwaysOnTop)`
2. Updated failing settings call sites to use wrappers and camelCase payload keys.
## Verification
### Commands Run
- [x] `npm --prefix frontend/tauri-ui run build`
- [x] `cargo check -p sprimo-tauri`
### Visual Checklist
- [x] Before screenshot(s): `issues/screenshots/issue3.png`
- [x] Before screenshot(s): `issues/screenshots/issue3-b.png`
- [ ] After screenshot(s): `issues/screenshots/issue3-after-YYYYMMDD-HHMMSS.png`
### Result
- Status: `Fix Implemented`
- Notes: compile/build checks pass; runtime visual verification still required.
## Status History
- `2026-02-14 00:00` - reporter - `Reported` - settings errors captured in `issue3.png` and `issue3-b.png`.
- `2026-02-14 00:00` - codex - `Triaged` - localized to invoke argument key mismatch.
- `2026-02-14 00:00` - codex - `Fix Implemented` - updated settings invoke keys to camelCase via typed wrappers.
## Closure
- Current Status: `Fix Implemented`
- Close Date:
- Owner:
- Linked PR/commit:

200
issues/issue4.md Normal file
View File

@@ -0,0 +1,200 @@
## Title
Packaged `sprimo-tauri` sprite rendering breaks after pack switch; default switch errors and
scaling stops applying.
## Severity
P1
## Environment
- OS: Windows
- App version/build: packaged release (`sprimo-tauri.exe`)
- Renderer/backend details: Tauri main overlay + settings pop-out
- Evidence screenshots:
- `issues/screenshots/issue4.png`
- `issues/screenshots/issue4-b.png`
- `issues/screenshots/issue4-c.png`
- `issues/screenshots/issue4-after-fix2-2026-02-14-145819.png`
- `issues/screenshots/issue4-after-fix4-2026-02-14-153233.png`
## Summary
In packaged runtime, sprite display is incorrectly split/tiled, switching to `default` can fail,
and scaling becomes ineffective after the error.
## Reproduction Steps
1. Run packaged `sprimo-tauri.exe` from ZIP extract.
2. Open settings window.
3. Switch character between `ferris` and `default`.
4. Observe main overlay rendering and debug output.
5. Change scale slider.
## Expected Result
- Sprite sheet is split into the correct frame grid regardless of image resolution.
- Pack switching works for both `ferris` and `default`.
- Scale changes continue to apply after pack changes.
## Actual Result
- Main overlay shows incorrectly split/tiled sprite sheet.
- Pack switch can produce runtime error and break subsequent behavior.
- Scale update stops working reliably after the error.
## Root Cause Analysis
1. Existing splitting logic relied on fixed pixel frame metadata that did not generalize to
packaged `sprite.png` dimension variants.
2. Pack metadata inconsistency:
- `assets/sprite-packs/ferris/manifest.json` used duplicated `id` (`default`), causing pack
identity ambiguity.
3. Settings/runtime flow then entered an unstable state after pack switch failures.
4. Renderer reload lifecycle in tauri UI was unsafe:
- `PixiPetRenderer::dispose` performed duplicate teardown (`ticker.destroy` + `app.destroy`),
which could trigger runtime `TypeError` during pack reload.
- Renderer replacement disposed previous renderer before new renderer creation succeeded, leaving
the view in a broken/cropped state on creation failures.
5. Chroma-key conversion tolerance removed most `#FF00FF` background but still left magenta fringe
on anti-aliased edges.
6. Scale fit used repeated position deltas and caused directional drift during repeated resizing.
7. API mismatch in tauri window module:
- runtime used `getCurrentWindow().currentMonitor()` but this API version exposes monitor lookup as
module function (`currentMonitor`), causing `TypeError` and skipping window fit.
8. Scale position math mixed physical window metrics (`outerPosition`/`innerSize`) with logical
set operations (`LogicalSize`/`LogicalPosition`), reintroducing cumulative drift in some DPI
contexts.
9. Ferris background keying needed adaptive key detection; fixed `#FF00FF` assumptions were still
too brittle for packaged atlas variants.
10. Scale-path physical positioning used non-integer coordinates in `setPosition`, triggering
runtime arg errors (`expected i32`) and bypassing window fit updates.
11. Monitor-fit cap remained too optimistic for large frame packs, so max scale could still exceed
practical visible bounds and appear clipped.
12. Ferris/demogorgon packaged backgrounds are gradient-magenta (not exact `#FF00FF`), requiring a
border-connected magenta-family mask instead of exact-key assumptions.
13. Clipping persisted because sprite rendering scale followed snapshot/requested scale directly
instead of fitting to the actual post-clamp window size.
## Fix Plan
1. Introduce generic splitter policy for `sprite.png`:
- fixed topology: `8` columns x `7` rows
- derive frame size from actual image dimensions
- keep chroma-key background handling (`#FF00FF`) in renderer
2. Validate animation frame indices against fixed frame count (`56`) for `sprite.png`.
3. Ensure pack apply path validates atlas geometry before committing `SetSpritePack`.
4. Fix ferris manifest ID uniqueness.
## Implementation Notes
Implemented:
1. `crates/sprimo-tauri/src/main.rs`
- Added `sprite.png`-specific frame derivation (`8x7`) from PNG dimensions.
- Added PNG header dimension decoding utility.
- Added animation frame index validation against fixed `56` frames for `sprite.png`.
- Applied validation in both `load_active_sprite_pack` and `set_sprite_pack`.
2. `assets/sprite-packs/ferris/manifest.json`
- Changed manifest `id` from `default` to `ferris`.
3. `docs/SPRITE_PACK_SCHEMA.md`
- Documented Tauri `sprite.png` override behavior and 8x7 derived frame policy.
4. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- Made renderer disposal idempotent and removed duplicate ticker destruction.
- Delayed DOM canvas replacement until atlas load succeeds.
- Improved chroma-key edge handling with soft alpha + magenta spill suppression.
5. `frontend/tauri-ui/src/main.tsx`
- Made pack reload transactional (keep old renderer until new renderer creation succeeds).
- Improved fit-window flow so scale apply continues after reload retries.
- Added targeted diagnostics for reload failures.
6. `frontend/tauri-ui/src/main.tsx`
- Changed scaling anchor to window center and clamped resized window position within current
monitor bounds.
7. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- Replaced tolerance-only chroma key with border-connected `#FF00FF` background flood-fill removal
and localized edge halo suppression.
8. `crates/sprimo-tauri/capabilities/default.json`
- Added `core:window:allow-current-monitor` permission for monitor bounds clamping.
9. `frontend/tauri-ui/src/main.tsx`
- switched monitor lookup to module-level `currentMonitor()` with safe fallback so window scaling
still applies even if monitor introspection is unavailable.
10. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- added fallback global key cleanup when border-connected background detection is too sparse.
11. `frontend/tauri-ui/src/main.tsx`
- moved scale resizing and positioning to physical units (`PhysicalSize`/`PhysicalPosition`) and
monitor selection at window-center point (`monitorFromPoint`).
12. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- added adaptive border-derived key color selection with fallback key cleanup pass.
13. `scripts/package_windows.py`
- tauri packaging now explicitly rebuilds UI bundle to avoid stale embedded `dist` output.
14. `frontend/tauri-ui/src/main.tsx`
- enforced integer physical positioning and monitor work-area size clamping to prevent set-position
arg failures and large-scale clipping.
15. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- switched ferris cleanup to hue/saturation/value magenta-band masking with connected background
removal and stronger fallback cleanup.
16. `frontend/tauri-ui/src/main.tsx`
- added stricter monitor work-area guard (`WINDOW_WORKAREA_MARGIN`) in both scale-cap and resize
clamp paths to prevent large-pack clipping at high scales.
17. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- added deterministic border-connected strong-magenta flood-fill cleanup pass so non-`#FF00FF`
gradient backgrounds are removed consistently in packaged ferris/demogorgon atlases.
18. `frontend/tauri-ui/src/main.tsx`
- changed scale flow to window-driven fit semantics: scale request resizes/clamps main window and
then persists effective scale derived from applied window size.
19. `frontend/tauri-ui/src/renderer/pixi_pet.ts`
- renderer sprite scale is now derived from current canvas/window size each layout pass, removing
clipping caused by mismatch between requested scale and bounded window dimensions.
## Verification
### Commands Run
- [ ] `just build-release-tauri`
- [ ] `just package-win-tauri`
- [ ] `just smoke-win-tauri`
- [x] `cargo check -p sprimo-tauri`
### Visual Checklist
- [x] Before screenshot(s): `issues/screenshots/issue4.png`
- [x] Before screenshot(s): `issues/screenshots/issue4-b.png`
- [x] Before screenshot(s): `issues/screenshots/issue4-c.png`
- [ ] After screenshot(s): `issues/screenshots/issue4-after-YYYYMMDD-HHMMSS.png`
### Result
- Status: `Fix Implemented`
- Notes: packaged runtime validation and after screenshots for this round are pending.
## Status History
- `2026-02-14 00:00` - reporter - `Reported` - packaged runtime failure screenshots attached.
- `2026-02-14 00:00` - codex - `Triaged` - localized to sprite splitting/pack identity behavior.
- `2026-02-14 00:00` - codex - `Fix Implemented` - applied 8x7 generic splitter policy and pack-ID correction.
- `2026-02-14 00:00` - reporter - `In Progress` - reported `issue4-after-fix1` still failing in packaged runtime.
- `2026-02-14 00:00` - codex - `Fix Implemented` - hardened renderer reload/dispose and chroma-key edge cleanup.
- `2026-02-14 00:00` - reporter - `In Progress` - remaining magenta ferris edge + scale drift reported.
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to border-connected chroma-key removal and center-anchored, monitor-clamped scale fit.
- `2026-02-14 00:00` - reporter - `In Progress` - reported `currentMonitor` TypeError and ferris magenta background still visible.
- `2026-02-14 00:00` - codex - `Fix Implemented` - corrected monitor API call and added fallback chroma cleanup pass.
- `2026-02-14 00:00` - reporter - `In Progress` - reported ferris magenta background still visible and scale drift recurrence.
- `2026-02-14 00:00` - codex - `Fix Implemented` - switched to physical-unit resize math, adaptive key detection, and packaging UI refresh enforcement.
- `2026-02-14 00:00` - reporter - `In Progress` - reported default clipping, ferris background still present, and set_position float arg error.
- `2026-02-14 00:00` - codex - `Fix Implemented` - added integer-safe physical setPosition and HSV magenta cleanup strategy.
- `2026-02-14 00:00` - reporter - `In Progress` - reported remaining default clipping and ferris magenta background persistence.
- `2026-02-14 00:00` - codex - `Fix Implemented` - tightened work-area scale guard and added border-connected strong-magenta cleanup pass.
- `2026-02-14 00:00` - reporter - `In Progress` - reported clipping still present on `default` and `demogorgon` after prior fixes.
- `2026-02-14 00:00` - codex - `Fix Implemented` - moved tauri scale to window-driven effective-fit persistence and renderer fit-to-window scaling.
## Closure
- Current Status: `Fix Implemented`
- Close Date:
- Owner:
- Linked PR/commit:

View File

@@ -153,6 +153,8 @@ def sha256_file(path: Path) -> str:
def package(frontend: FrontendLayout) -> PackageLayout:
version = read_version()
ensure_assets()
if frontend.id == "tauri":
run(["npm", "--prefix", "frontend/tauri-ui", "run", "build"])
binary = ensure_release_binary(frontend)
runtime_files = ensure_runtime_files(frontend, binary.parent)