Skip to content

refactor(stage): single validated command layer for animation, avatar, environment and audio - #50

Merged
rosspeili merged 7 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:refactor/stage-command-layer
Aug 15, 2026
Merged

refactor(stage): single validated command layer for animation, avatar, environment and audio#50
rosspeili merged 7 commits into
ARPAHLS:mainfrom
AUDOSt0ck1ng:refactor/stage-command-layer

Conversation

@AUDOSt0ck1ng

@AUDOSt0ck1ng AUDOSt0ck1ng commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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's useState setters.

  • src/lib/stageCommands.jsresolveStageCommand(command, payload, context) returns either an action or { code, error }. Pure, React- and asset-free, so node --test loads it directly.
  • src/hooks/useStageCommands.js — the only place an action is applied.
  • src/config/environmentSelection.js — the selection shape, defaultColor and a normalizer, extracted from environments.js so it is reachable without pulling in the bundled GIFs. normalizeUserSettings now shares it instead of parsing the selection inline.
  • findAnimation(catalog, query) — matches an id, then a label, and returns null on 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.play needs the animationRequest counter as well as the id — re-selecting the clip that is already playing has to restart it, and VrmAvatar's effect keys off the counter for exactly that case.
  • avatar.set needs setAvatarReady(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.set deliberately takes ids only: bundled labels are Avatar 1Avatar 3 and 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. handleAvatarChange cleared avatarReady on every click, but re-picking the current character leaves modelPath unchanged, so VrmAvatar's load effect never re-ran and onLoaded never fired to set it back — the canvas stayed visibility: hidden until you picked a different avatar or restarted. Pre-existing on main; handleResetAllSettings already 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 owns modelPath while loaded.

Changed: a malformed environment in a hand-edited config.yaml — an env with no id, a non-hex colour, a value with no type — now falls back to the default colour instead of being half-accepted (previously: silently stars, 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) and npm run build all pass.

Smoke-tested in dev:desktop, covering what the unit tests cannot:

  • re-selecting the current animation restarts it; the Default sequence plays through
  • re-selecting the current avatar is a no-op; a different avatar fades correctly; swapping away from a VRoid Hub character and back
  • all seven environment paths (built-ins, None, colour picker, Use color, Reset, custom tiles)
  • custom .vrma / .vrm / image folders — selection, and Clear back to the bundled catalogs
  • persistence across restart, and Reset all settings

Follow-ups

🤖 Generated with Claude Code

AUDOSt0ck1ng and others added 4 commits August 14, 2026 19:56
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
AUDOSt0ck1ng marked this pull request as draft August 14, 2026 12:27
AUDOSt0ck1ng and others added 3 commits August 14, 2026 20:35
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>
@AUDOSt0ck1ng

AUDOSt0ck1ng commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

I've tested related functions:
Animations

  • Pick Peace Sign from the bar menu
  • Pick the same animation twice — it restarts (covers the replay counter)
  • Pick Default — the full sequence plays through
  • Pick any clip after Rest

Avatars

  • Switch between Avatar 1 / 2 / 3
  • Load a VRoid Hub character, then pick a bundled avatar — the Hub character stands down
  • Reactivate the Hub character afterwards

Environments

  • Stars / Code / Bloom
  • None
  • Drag the colour picker
  • Use color
  • Reset
  • Expand Custom and pick a tile

@rosspeili

Copy link
Copy Markdown
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 docs/architecture/overview.md but can be done in a later pr <3 Merging this as is. Top PR as always <3

@rosspeili
rosspeili merged commit 26c69c2 into ARPAHLS:main Aug 15, 2026
1 check passed
rosspeili added a commit that referenced this pull request Aug 16, 2026
Fold Unreleased into 0.7.0 (Motion Deck, stage commands, custom VRMA, env posters), point download links and citation metadata at the new installer, and refresh the roadmap for shipped #24/#50/#51 work.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants