From 2ea7f2b4670a18361e1a16d693fef07f85d11e6a Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 4 Sep 2026 21:12:29 +0000 Subject: [PATCH] unify the CoS avatar-style vocabulary into one exported registry (#6253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AVATAR_STYLE_LABELS, LAZY_AVATARS, CANVAS_AVATAR_STYLES, and the server's avatarStyle zod enum were four hand-maintained lists with nothing enforcing agreement between them — a style added to one but not the enum was a settings 400 at runtime. Add client/src/lib/avatarStyles.js as the single source of truth (AVATAR_STYLES = [{id, label, webgl}]), with AVATAR_STYLE_IDS, AVATAR_STYLE_LABELS, and WEBGL_AVATAR_STYLE_IDS derived from it. The picker labels, the WebGL-stage set, and the server's zod enum now derive from the registry directly; the server imports the client leaf module at runtime the way server/lib/personaTraitBlend.js already imports `clamp` from client/src/utils/formatters.js, since it carries no transitive dependencies. LAZY_AVATARS stays a hand-written id->lazy() map (it holds real component references, not data), so a new completeness test in ChiefOfStaff.test.jsx fails loudly if a registry style is missing from both LAZY_AVATARS and the new INLINE_RENDERED_AVATAR_STYLES set. --- client/src/components/cos/constants.js | 16 ++------- client/src/lib/README.md | 1 + client/src/lib/avatarStyles.js | 38 +++++++++++++++++++++ client/src/lib/avatarStyles.test.js | 25 ++++++++++++++ client/src/lib/index.js | 1 + client/src/pages/ChiefOfStaff.jsx | 16 ++++++--- client/src/pages/ChiefOfStaff.test.jsx | 25 +++++++++++++- server/routes/cosStatusRoutes.js | 6 +++- server/routes/cosStatusRoutesAvatar.test.js | 11 ++++-- 9 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 client/src/lib/avatarStyles.js create mode 100644 client/src/lib/avatarStyles.test.js diff --git a/client/src/components/cos/constants.js b/client/src/components/cos/constants.js index 419ec92a21..14cb3a479d 100644 --- a/client/src/components/cos/constants.js +++ b/client/src/components/cos/constants.js @@ -17,6 +17,7 @@ import { MessageCircle } from 'lucide-react'; import { normalizeReviewerSlug, REVIEWER_VALUES } from '../../lib/reviewerPins'; +import { AVATAR_STYLE_LABELS } from '../../lib/avatarStyles'; import { inPlaceClipName } from '../../utils/animationClips'; export const TABS = [ @@ -536,20 +537,7 @@ export const getDomainBudget = (config, domainId) => { }; // Avatar style labels for display -export const AVATAR_STYLE_LABELS = { - svg: 'Digital (SVG)', - cyber: 'Cyberpunk (3D)', - sigil: 'Arcane Sigil (3D)', - esoteric: 'Esoteric (3D)', - nexus: 'Neural Nexus (3D)', - muse: 'Cyber Muse (3D)', - // Kestrel Neon's rotating wireframe icosahedron — 2D canvas, no WebGL needed. - core: 'Core Assembly (Canvas)', - // Bundled CC0 Kenney Mini Characters — animated rigged GLB avatars. - miniMaleC: 'Mini Character — Male (3D)', - miniFemaleD: 'Mini Character — Female (3D)', - ascii: 'Minimalist (ASCII)' -}; +export { AVATAR_STYLE_LABELS }; // Dynamic avatar rules - maps task context to avatar styles // Priority order: provider > analysisType > taskType > priority > fallback diff --git a/client/src/lib/README.md b/client/src/lib/README.md index 5a2bc63a06..3720974533 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -102,6 +102,7 @@ grep -i "what you want to do" client/src/lib/README.md | `agentResumeOutcome.js` | `agentResumeMessage(result, messages, fallback)` — the toast wording for a CoS agent resume/relaunch result. The server answers with a `mode` (what it did to the task) plus `spawned`/`spawnHold` (whether it also STARTED it — a resumed task is force-spawned when a slot is free, and `spawnHold` names the refusal when it isn't). `messages` is keyed by mode as `{ queued, running? }`; a mode with no `running` variant is one that deliberately queues nothing, so it can never be reported as started. Shared by the Resume dialog (`cos/tabs/AgentsTab.jsx`) and `cos/tabs/RelaunchAgentModal.jsx` so neither can say "queued" for a run that already started, or omit the reason it didn't. | | `appIdentity.js` | `PORTOS_APP_ID` — stable id of the baseline PortOS app (mirrors `server/lib/appIdentity.js`). Dependency-free so a module reachable from a node-env server test (e.g. `components/apps/constants.js`) can identify PortOS without importing `services/apiCore.js`, which pulls in React via `ui/Toast`. `apiCore.js` re-exports it, so `import { PORTOS_APP_ID } from '../services/api'` still works. | | `applyManuscriptEdits.js` | `applyEditsToContent(content, edits, anchorQuote)` — PREVIEW-ONLY client mirror of the server's accept splice (`server/services/pipeline/manuscriptFix.js`): locate each `find` (nearest the anchor when recurring), drop overlaps, replace bottom-up. Powers the Manuscript editor's whole-manuscript impact preview. | +| `avatarStyles.js` | `AVATAR_STYLES` — the single source of truth for the CoS avatar-style vocabulary (`{ id, label, webgl }`), plus `AVATAR_STYLE_IDS`, `AVATAR_STYLE_LABELS`, and `WEBGL_AVATAR_STYLE_IDS` derived from it. Consumed by the picker labels (`components/cos/constants.js`), the lazy-load map and WebGL-stage set (`pages/ChiefOfStaff.jsx`), and — imported directly, dependency-free — the server's `avatarStyle` zod enum (`server/routes/cosStatusRoutes.js`). | | `assessmentTuningNotice.js` | `tuningNoticeChip(entry)` — the short form of how a measured local-model assessment says its launch configuration did NOT take effect. `tuningApplied === false` covers two opposite cases and `tuningKey` separates them: a TUNED run whose knobs never reached the daemon (`tuning not applied`), versus an UNTUNED run PortOS could not put back on backend defaults (`not at defaults`) — #4759. Shared by `ModelThroughputReport.jsx`'s table cell and `LocalModelAssessments.jsx`'s result toast; the long form is the server's exclusion reason in `getAssessmentReport`. | | `callAudioBridge.js` | Pure device/format helpers for the call-host page (`pages/VoiceCallHost.jsx`, #5308 + #5311) — shared by its Call mode (FaceTime Audio) and its Capture system audio mode (meeting transcription). `missingCallHostApis(scope)` lists every required browser API this browser lacks (MediaStreamTrackProcessor/Generator, AudioWorklet, `enumerateDevices`, `setSinkId`, Web Locks) so the page names all the gaps at once instead of one per reload; `findAudioDevice(devices, label, kind)` matches a device label EXACTLY after trim+case-fold (a substring match would resolve "BlackHole 1" to the 16ch device, indistinguishable from a dead call); `describeDeviceProblem(devices, {inputLabel, outputLabel})` returns the one blocking message or `null`, and deliberately blames MICROPHONE PERMISSION rather than a missing driver when every label comes back blank — an ungranted tab enumerates unlabeled devices, and "reinstall BlackHole" would send the user to fix the wrong thing; `outputLabel` is OPTIONAL — capture mode only listens, so omitting it skips the output-device check entirely rather than reporting a device named "undefined" as missing. Format side: `downmixToMono(channels)` (BlackHole 16ch carries the call on all channels; truncates to the shortest rather than reading past one), `resampleTo16k(samples, fromRate)` (linear, 48k→16k for whisper), `floatToInt16` (CLAMPED — wrapping would flip a hot sample's sign into an audible click), `rmsLevel` for the input meter, and `buildTestTone` (faded in/out so it cannot click through the call). Plus `CALL_HOST_LOCK` (the Web Lock name that stops a second tab double-answering — shared by both modes, since they read the same physical device) and `CALL_FRAME_SAMPLES`. | | `audioRecorder.js` | `startMemoRecording()` → `{ stream, stop, cancel }` handle whose `stop()` resolves to `{ audioBase64, mimeType, peak, durationMs }` (16 kHz mono WAV, base64); the live `stream` lets a tuner tap the same mic. `createStreamAnalyser(stream, { fftSize })` → `{ analyser, context, close }` taps an `AnalyserNode` off an existing stream (no second getUserMedia). Plus `blobToWav16k`, `encodePcmToWav`, `pickRecordingMimeType`, `arrayBufferToBase64`. Standalone one-shot memo capture for catalog voice ingest — NOT the live voice-agent recorder in `services/voiceClient.js`. **`openAnalysisMic({ getUserMedia })`** → `{ stream, processing }` is the ONE way a pitch-analysis surface opens a mic: it requests `ANALYSIS_AUDIO_CONSTRAINTS` (echo cancellation / noise suppression / AGC off — plain booleans, never `{ exact }`, so a browser that can't honor one still opens the mic) and reads `track.getSettings()` back so the UI can say what actually stuck. `readAppliedProcessing(stream)` reports each stage as `true` / `false` / `null` (browser didn't say — never collapse unknown into off) and `hasUnwantedProcessing(p)` gates the warning. Memo capture deliberately keeps the speech-tuned defaults; Whisper wants them. | diff --git a/client/src/lib/avatarStyles.js b/client/src/lib/avatarStyles.js new file mode 100644 index 0000000000..0dc806634a --- /dev/null +++ b/client/src/lib/avatarStyles.js @@ -0,0 +1,38 @@ +/** + * Single source of truth for the CoS avatar-style vocabulary. Every consumer + * that used to hand-maintain its own list derives from `AVATAR_STYLES` + * instead (#6253): the picker labels (`components/cos/constants.js`), the + * lazy-load map and WebGL-stage set (`pages/ChiefOfStaff.jsx`), and the + * server's `avatarStyle` zod enum (`server/routes/cosStatusRoutes.js`, + * imported directly — this leaf has no transitive deps, so it's safe from + * the server workspace the way `personaTraitBlend.js`'s `clamp` import is). + * + * `webgl: true` marks a style that needs the three.js canvas stage — + * `CANVAS_AVATAR_STYLES` derives from this flag. The 2D `core` canvas style + * and the inline `svg`/`ascii` styles are deliberately `webgl: false`. + */ + +export const AVATAR_STYLES = [ + { id: 'svg', label: 'Digital (SVG)', webgl: false }, + { id: 'cyber', label: 'Cyberpunk (3D)', webgl: true }, + { id: 'sigil', label: 'Arcane Sigil (3D)', webgl: true }, + { id: 'esoteric', label: 'Esoteric (3D)', webgl: true }, + { id: 'nexus', label: 'Neural Nexus (3D)', webgl: true }, + { id: 'muse', label: 'Cyber Muse (3D)', webgl: true }, + // Kestrel Neon's rotating wireframe icosahedron — 2D canvas, no WebGL needed. + { id: 'core', label: 'Core Assembly (Canvas)', webgl: false }, + // Bundled CC0 Kenney Mini Characters — animated rigged GLB avatars. + { id: 'miniMaleC', label: 'Mini Character — Male (3D)', webgl: true }, + { id: 'miniFemaleD', label: 'Mini Character — Female (3D)', webgl: true }, + { id: 'ascii', label: 'Minimalist (ASCII)', webgl: false }, +]; + +export const AVATAR_STYLE_IDS = AVATAR_STYLES.map((style) => style.id); + +export const AVATAR_STYLE_LABELS = Object.fromEntries( + AVATAR_STYLES.map((style) => [style.id, style.label]) +); + +export const WEBGL_AVATAR_STYLE_IDS = new Set( + AVATAR_STYLES.filter((style) => style.webgl).map((style) => style.id) +); diff --git a/client/src/lib/avatarStyles.test.js b/client/src/lib/avatarStyles.test.js new file mode 100644 index 0000000000..7f512b8a65 --- /dev/null +++ b/client/src/lib/avatarStyles.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { AVATAR_STYLES, AVATAR_STYLE_IDS, AVATAR_STYLE_LABELS, WEBGL_AVATAR_STYLE_IDS } from './avatarStyles'; + +describe('avatarStyles registry', () => { + it('has a unique id for every style', () => { + expect(new Set(AVATAR_STYLE_IDS).size).toBe(AVATAR_STYLES.length); + }); + + it('derives AVATAR_STYLE_LABELS with one entry per style', () => { + expect(Object.keys(AVATAR_STYLE_LABELS).sort()).toEqual([...AVATAR_STYLE_IDS].sort()); + for (const style of AVATAR_STYLES) { + expect(AVATAR_STYLE_LABELS[style.id]).toBe(style.label); + } + }); + + it('derives WEBGL_AVATAR_STYLE_IDS from the webgl flag only', () => { + for (const style of AVATAR_STYLES) { + expect(WEBGL_AVATAR_STYLE_IDS.has(style.id)).toBe(style.webgl); + } + }); + + it('excludes the 2D core canvas style from the WebGL set', () => { + expect(WEBGL_AVATAR_STYLE_IDS.has('core')).toBe(false); + }); +}); diff --git a/client/src/lib/index.js b/client/src/lib/index.js index 2a521d1fc3..c5a399fcfe 100644 --- a/client/src/lib/index.js +++ b/client/src/lib/index.js @@ -78,6 +78,7 @@ export * from './a11yKeyboard.js'; export * from './agentResumeOutcome.js'; export * from './appIdentity.js'; export * from './applyManuscriptEdits.js'; +export * from './avatarStyles.js'; export * from './assessmentTuningNotice.js'; export * from './audioRecorder.js'; export * from './callAudioBridge.js'; diff --git a/client/src/pages/ChiefOfStaff.jsx b/client/src/pages/ChiefOfStaff.jsx index ab9eb6a621..93b8717b6e 100644 --- a/client/src/pages/ChiefOfStaff.jsx +++ b/client/src/pages/ChiefOfStaff.jsx @@ -8,6 +8,7 @@ import * as api from '../services/api'; import { isRiggedAvatarStyle, riggedRecordForStyle, useAvatarCapabilities } from '../hooks/useAvatarCapabilities'; import { coalesce } from '../utils/coalesce'; import { sameJsonShape } from '../lib/sameJsonShape'; +import { WEBGL_AVATAR_STYLE_IDS } from '../lib/avatarStyles'; import { Play, Pause, Square, Clock, CheckCircle, AlertCircle, Cpu, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Brain, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import toast from '../components/ui/Toast'; import BrailleSpinner from '../components/BrailleSpinner'; @@ -61,7 +62,13 @@ const BriefingTab = lazy(() => import('../components/cos/tabs/BriefingTab')); // Three.js-based avatars lazy-loaded so the R3F stack isn't bundled unless the // user's chosen avatar style actually needs it. `core` is a plain 2D canvas // (no three.js) but stays lazy so the default SVG path loads nothing extra. -const LAZY_AVATARS = { +// Every registry id (`lib/avatarStyles.js`) EXCEPT `svg`/`ascii` renders through +// a lazily-loaded component here — those two fall through to the inline +// `CoSCharacter` default below. `ChiefOfStaff.avatarStyles.test.jsx` fails if a +// registry id is missing from this map (or from the inline-rendered pair). +export const INLINE_RENDERED_AVATAR_STYLES = new Set(['svg', 'ascii']); + +export const LAZY_AVATARS = { cyber: lazy(() => import('../components/cos/CyberCoSAvatar')), sigil: lazy(() => import('../components/cos/SigilCoSAvatar')), esoteric: lazy(() => import('../components/cos/EsotericCoSAvatar')), @@ -79,10 +86,9 @@ const LAZY_AVATARS = { // avatar so three.js stays out of the main chunk until it is picked. const LazyRiggedAvatar = lazy(() => import('../components/cos/MiniCharacterCoSAvatar')); -const CANVAS_AVATAR_STYLES = new Set([ - 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', - 'miniMaleC', 'miniFemaleD', -]); +// `CANVAS_AVATAR_STYLES` means "needs the WebGL/three.js stage" — derives +// directly from the registry's `webgl` flag (`lib/avatarStyles.js`). +const CANVAS_AVATAR_STYLES = WEBGL_AVATAR_STYLE_IDS; // Shared brand gradient for the "CoS" wordmark headings (clipped to text). const COS_TITLE_GRADIENT = 'linear-gradient(135deg, #6366f1, #8b5cf6, #06b6d4)'; diff --git a/client/src/pages/ChiefOfStaff.test.jsx b/client/src/pages/ChiefOfStaff.test.jsx index d30339fc87..17f2c39cf9 100644 --- a/client/src/pages/ChiefOfStaff.test.jsx +++ b/client/src/pages/ChiefOfStaff.test.jsx @@ -74,7 +74,8 @@ vi.mock('../components/cos/MiniCharacterCoSAvatar', () => ({ ), })); -const { default: ChiefOfStaff, SPEAKING_MS } = await import('./ChiefOfStaff'); +const { default: ChiefOfStaff, SPEAKING_MS, LAZY_AVATARS, INLINE_RENDERED_AVATAR_STYLES } = await import('./ChiefOfStaff'); +const { AVATAR_STYLE_IDS } = await import('../lib/avatarStyles'); const config = { avatarStyle: 'svg', @@ -944,3 +945,25 @@ describe('CoS agent panel layout sizing', () => { }); }); +// #6253 — every avatar style in the shared registry (`lib/avatarStyles.js`) +// must render through either the lazy-load map or the inline-rendered pair, +// or a style added to the registry alone silently falls through to the +// default `CoSCharacter` render instead of failing loudly. +describe('avatar-style registry coverage', () => { + it('has a render path for every registry id, and no extra map entries', () => { + const lazyIds = new Set(Object.keys(LAZY_AVATARS)); + for (const id of AVATAR_STYLE_IDS) { + expect( + lazyIds.has(id) || INLINE_RENDERED_AVATAR_STYLES.has(id), + `avatar style "${id}" is missing from both LAZY_AVATARS and INLINE_RENDERED_AVATAR_STYLES` + ).toBe(true); + } + for (const id of lazyIds) { + expect(AVATAR_STYLE_IDS, `LAZY_AVATARS has an entry for unknown style "${id}"`).toContain(id); + } + for (const id of INLINE_RENDERED_AVATAR_STYLES) { + expect(AVATAR_STYLE_IDS, `INLINE_RENDERED_AVATAR_STYLES has an entry for unknown style "${id}"`).toContain(id); + } + }); +}); + diff --git a/server/routes/cosStatusRoutes.js b/server/routes/cosStatusRoutes.js index cf2e3c080c..fc55099ac4 100644 --- a/server/routes/cosStatusRoutes.js +++ b/server/routes/cosStatusRoutes.js @@ -12,6 +12,10 @@ import { validateRequest } from '../lib/validation.js'; import { z } from 'zod'; import { DOMAIN_IDS, DOMAIN_MODES } from '../lib/domainAutonomy.js'; import { AVATAR_VARIANT_PATTERN, RIGGED_VARIANT_PREFIX } from '../lib/avatarVariants.js'; +// Single source of truth for the avatar-style vocabulary (#6253) — a +// dependency-free leaf, safe to import from the server the way +// `server/lib/personaTraitBlend.js` imports `clamp` from client `utils/formatters.js`. +import { AVATAR_STYLE_IDS } from '../../client/src/lib/avatarStyles.js'; import { BUDGET_LIMIT_FIELDS } from '../lib/domainBudgets.js'; import { persistentMindCapabilitiesSchema } from '../lib/persistentMindCapabilities.js'; import { persistentMindProfileSchema } from '../lib/persistentMindProfile.js'; @@ -46,7 +50,7 @@ export const cosConfigSchema = z.object({ // the same traversal guard `server/routes/avatar.js` enforces), so unknown // spellings still 400 here instead of persisting a style nothing can render. avatarStyle: z.union([ - z.enum(['svg', 'ascii', 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', 'core', 'miniMaleC', 'miniFemaleD']), + z.enum(AVATAR_STYLE_IDS), z.string().startsWith(RIGGED_VARIANT_PREFIX).refine( (value) => AVATAR_VARIANT_PATTERN.test(value.slice(RIGGED_VARIANT_PREFIX.length)), ), diff --git a/server/routes/cosStatusRoutesAvatar.test.js b/server/routes/cosStatusRoutesAvatar.test.js index 8638f000d2..5e19f522c4 100644 --- a/server/routes/cosStatusRoutesAvatar.test.js +++ b/server/routes/cosStatusRoutesAvatar.test.js @@ -10,11 +10,16 @@ vi.mock('../services/taskWatcher.js', () => ({})); vi.mock('../services/memoryEmbeddings.js', () => ({ reinitialize: vi.fn() })); import { cosConfigSchema } from './cosStatusRoutes.js'; +import { AVATAR_STYLE_IDS } from '../../client/src/lib/avatarStyles.js'; describe('cosConfigSchema avatarStyle', () => { - it('accepts built-in styles and rigged record spellings', () => { - expect(cosConfigSchema.safeParse({ avatarStyle: 'muse' }).success).toBe(true); - expect(cosConfigSchema.safeParse({ avatarStyle: 'core' }).success).toBe(true); + it('accepts every style in the shared registry, so a style added there is never a settings 400', () => { + for (const id of AVATAR_STYLE_IDS) { + expect(cosConfigSchema.safeParse({ avatarStyle: id }).success, `avatarStyle "${id}"`).toBe(true); + } + }); + + it('accepts rigged record spellings', () => { expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-image3d-abc-123' }).success).toBe(true); });