refactor(stage): single validated command layer for animation, avatar, environment and audio - #50
Merged
rosspeili merged 7 commits intoAug 15, 2026
Conversation
Groundwork for a single trigger surface (Refs #6): hotkeys, the local bus and keyword matching all need to validate a request before applying it, and neither catalog could tell a caller that its query missed. - findAnimation(catalog, query) matches an id, then a label, and returns null on a miss. getAnimationById cannot serve this: it falls back to the catalog's first entry on purpose, so an unknown clip would silently play something else. Ids beat labels so a custom folder cannot shadow one. - environmentSelection.js holds the selection shape, defaultColor and a normalizer, free of asset imports so node --test can load it. environments.js re-exports both, so existing importers are unchanged. - normalizeUserSettings now uses that normalizer instead of parsing the selection inline, so config.yaml and a runtime selection agree on what is usable. Shape validation only: an env id cannot be checked against the catalog here, because a custom folder is scanned long after settings are read. Three malformed-config cases now fall back to the default lavender instead of being half-accepted, matching how every other unreadable field in the schema is treated: - {type: env} with no id (was: silently 'stars') - {type: color, value: red} (was: stored as-is, rendered as the default) - {value: '#ff0080'} with no type (was: treated as color) lint, test and build pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing calls this yet; the next commit rewires the panels onto it. Splits "what was asked for" from "what to apply", because the surfaces that are coming (hotkeys, the local bus, keyword matching — Refs #6) all arrive with unvalidated input and all need an answer, not a silent no-op: - resolveStageCommand(command, payload, context) is pure and returns either an action or a { code, error }. Asset- and React-free, so node --test loads it; import specifiers keep their .js extension for that reason. - useStageCommands applies an action, and is the only thing that does. The multi-setter sequences that used to be spelled out per handler now exist once: the animationRequest bump that makes a repeated clip restart, and the avatarReady/hubActive ordering that keeps the stage from stalling at opacity 0. animation.play matches a label as well as an id, so a caller can ask for "Peace Sign" without knowing that a custom folder hashes ids from file paths. avatar.set takes ids only: bundled labels are "Avatar 1"…"Avatar 3" and a folder's labels are filenames, so matching them buys ambiguity and nothing else. animation.default resolves against the live catalog, landing on the first selectable clip when a custom folder has no Default sequence. Resolution is synchronous and reports acceptance only — a VRM or an environment image may still be loading when a command returns. lint, test and build pass; 11 new tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The picker, the bar menu and the voice panel now call runCommand instead of receiving the stage's setters, so the four v1 actions have one implementation each (Refs #6). No behaviour change intended. - AvatarStage builds the command context (live animation catalog, avatar ids, selectable environment ids, runtime audio sources) and owns runCommand; the handlers it passes down are one-line wrappers. - handleAnimationChange and handleAvatarChange are gone: their multi-setter bodies live in useStageCommands. - PalettePanel takes onEnvironmentChange in place of setSelectedBg, and VoicePanel onAudioSourceChange in place of setAudioSourceId. Left on raw setters deliberately: camera, light and avatar transform, which are not part of the v1 command vocabulary; the audio file and window pickers, which are UI-only; and the internal invalidation paths in AvatarStage — a custom folder that disappears is not a user request and must not be validated against a catalog it has just left. lint, test and build pass. Needs a desktop smoke test of the custom-folder paths, which no unit test covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #6 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AUDOSt0ck1ng
marked this pull request as draft
August 14, 2026 12:27
Same behaviour, less prose. The comments that survived are the ones stating something the code cannot: why findAnimation exists next to getAnimationById, why the replay counter and the avatarReady ordering matter, why the import specifiers carry .js. Removed the ones that restated their own line, and the four separate places that each explained that shape validation cannot check whether an id exists — it is stated once now, in environmentSelection.js. Also hardens resolveStageCommand's context read: parameter defaults cover a missing key but not a null one, so a null id list would have thrown from a function whose contract is that it never does. The hostile-input test only varied the command, so it did not catch this; it now varies the context too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking the avatar already on stage cleared avatarReady, but modelPath did not change, so VrmAvatar's load effect never re-ran and `onLoaded` never fired to set it back — the canvas stayed `visibility: hidden` and the whole overlay looked like it had crashed. Pre-existing on main; handleAvatarChange had the same unconditional reset, and handleResetAllSettings already guards against the identical hazard. avatarReady is now cleared only when the model actually reloads. An active VRoid Hub character is the exception: it owns modelPath, so standing it down is a real reload even when the avatar id is unchanged. Needs a manual check — the picker is the only way to send this, and the hook has no unit test. Refs #6 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #6 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
|
I've tested related functions:
Avatars
Environments
|
AUDOSt0ck1ng
marked this pull request as ready for review
August 14, 2026 12:47
Contributor
|
Thanks a lot @AUDOSt0ck1ng, this is the right groundwork for #6 / #7 / #24. and thanks for the incosistency fixes at the same time, could be a good idea to name the command layer in |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Gives the stage a single validated entry point for the state changes an external trigger can ask for, replacing the pattern of handing panels
AvatarStage'suseStatesetters.src/lib/stageCommands.js—resolveStageCommand(command, payload, context)returns either an action or{ code, error }. Pure, React- and asset-free, sonode --testloads it directly.src/hooks/useStageCommands.js— the only place an action is applied.src/config/environmentSelection.js— the selection shape,defaultColorand a normalizer, extracted fromenvironments.jsso it is reachable without pulling in the bundled GIFs.normalizeUserSettingsnow shares it instead of parsing the selection inline.findAnimation(catalog, query)— matches an id, then a label, and returnsnullon a miss.v1 vocabulary matches the bus proposal in #6:
animation.play,animation.default,avatar.set,environment.set,audio.source.Carries one bug fix that the refactor surfaced — see Behaviour changes below.
Why
#6's discussion settled on the local bus first and MCP later as a thin adapter, with the explicit constraint that keyword triggers (#7) and hotkeys (#24) drive the same play/set paths rather than each reimplementing them. That surface did not exist: the only way to change animation, avatar or environment was a React prop, and the handlers behind them were multi-setter sequences spelled out inline.
Two of those sequences are easy to get wrong and neither is obvious from the call site:
animation.playneeds theanimationRequestcounter as well as the id — re-selecting the clip that is already playing has to restart it, andVrmAvatar's effect keys off the counter for exactly that case.avatar.setneedssetAvatarReady(false), but only when the model path will actually change — which is where the bug below was hiding.Resolving separately from applying is what lets a caller be told why a request failed, which a bus needs and a click does not.
avatar.setdeliberately takes ids only: bundled labels areAvatar 1…Avatar 3and a custom folder's labels are filenames, so matching them buys ambiguity and no expressiveness. Animations are the opposite — the label is the meaningful name — so they match both.Resolution is synchronous and reports acceptance only; a VRM or an environment image may still be loading when a command returns. That will shape the bus's response semantics.
Not in scope
No HTTP server, no preload channel, no settings schema change — those are #6 itself. Camera, light and avatar transform stay on raw setters, as they are not part of the v1 vocabulary.
AvatarStage's internal invalidation paths (a custom folder that disappears mid-session) also stay on direct setters: that is not a user request and must not be validated against a catalog it has just left.Behaviour changes
Fixed: clicking the avatar already on stage made the companion disappear.
handleAvatarChangeclearedavatarReadyon every click, but re-picking the current character leavesmodelPathunchanged, soVrmAvatar's load effect never re-ran andonLoadednever fired to set it back — the canvas stayedvisibility: hiddenuntil you picked a different avatar or restarted. Pre-existing onmain;handleResetAllSettingsalready guards against the identical hazard, with a comment noting it "looked like a crash". The flag is now cleared only when the model actually reloads, with an active VRoid Hub character counting as one since it ownsmodelPathwhile loaded.Changed: a malformed
environmentin a hand-editedconfig.yaml— anenvwith noid, a non-hex colour, a value with notype— now falls back to the default colour instead of being half-accepted (previously: silentlystars, stored as-is, and treated as a colour respectively). This matches how every other unreadable field in the schema is treated, and it is what makes the same validator safe to point at bus input.Nothing else changes.
Testing
npm run lint,npm test(69 passing, 24 new) andnpm run buildall pass.Smoke-tested in
dev:desktop, covering what the unit tests cannot:.vrma/.vrm/ image folders — selection, and Clear back to the bundled catalogsFollow-ups
keydownlistener callingrunCommandelectron/vroid-oauth-server.cjsis a working loopback-server precedent; needs a preload channel and abusblock in the settings schemafindAnimation→runCommand🤖 Generated with Claude Code