@elizaos/app-core 2.0.0-alpha.67 → 2.0.0-alpha.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elizaos/app-core",
3
- "version": "2.0.0-alpha.67",
3
+ "version": "2.0.0-alpha.69",
4
4
  "description": "Shared application core for Milady shells and white-label apps.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,8 +68,8 @@
68
68
  "@capacitor/haptics": "8.0.0",
69
69
  "@capacitor/keyboard": "8.0.0",
70
70
  "@capacitor/preferences": "^8.0.1",
71
- "@elizaos/autonomous": "2.0.0-alpha.67",
72
- "@elizaos/ui": "2.0.0-alpha.67",
71
+ "@elizaos/autonomous": "2.0.0-alpha.69",
72
+ "@elizaos/ui": "2.0.0-alpha.69",
73
73
  "@sparkjsdev/spark": "^0.1.10",
74
74
  "lucide-react": "^0.575.0",
75
75
  "three": "^0.182.0",
@@ -87,5 +87,5 @@
87
87
  "typescript": "^5.9.3",
88
88
  "vitest": "^4.0.18"
89
89
  },
90
- "gitHead": "197684308ba4462a195efc7d60b2193c9943a9ea"
90
+ "gitHead": "774937c9cbefd3d95fc181ca40295bb4fd5a7d3b"
91
91
  }
@@ -6,6 +6,10 @@
6
6
 
7
7
  import type { CharacterData, ElizaClient } from "../api/client";
8
8
 
9
+ type MessageExampleGroup = {
10
+ examples: Array<{ name: string; content: { text: string } }>;
11
+ };
12
+
9
13
  export interface CharacterActionContext {
10
14
  client: ElizaClient;
11
15
  setCharacterData: (data: CharacterData | null) => void;
@@ -51,6 +55,165 @@ export async function loadCharacter(
51
55
  ctx.setCharacterLoading(false);
52
56
  }
53
57
 
58
+ function extractLikelyJson(input: string): string {
59
+ const trimmed = input.trim();
60
+ if (!trimmed) return trimmed;
61
+
62
+ const withoutFences = trimmed
63
+ .replace(/^```(?:json)?\s*/i, "")
64
+ .replace(/\s*```$/i, "")
65
+ .trim();
66
+ if (withoutFences.startsWith("{") || withoutFences.startsWith("[")) {
67
+ return withoutFences;
68
+ }
69
+
70
+ const firstBracket = withoutFences.indexOf("[");
71
+ const firstBrace = withoutFences.indexOf("{");
72
+ const starts = [firstBracket, firstBrace].filter((index) => index >= 0);
73
+ if (starts.length === 0) return withoutFences;
74
+
75
+ const start = Math.min(...starts);
76
+ const opener = withoutFences[start];
77
+ const closer = opener === "[" ? "]" : "}";
78
+ const end = withoutFences.lastIndexOf(closer);
79
+ if (end <= start) return withoutFences;
80
+
81
+ return withoutFences.slice(start, end + 1);
82
+ }
83
+
84
+ function normalizeSpeakerName(
85
+ rawName: unknown,
86
+ fallbackAgentName: string,
87
+ options: { fallbackMissingSpeaker?: boolean } = {},
88
+ ): string {
89
+ const fallbackMissingSpeaker = options.fallbackMissingSpeaker ?? true;
90
+ if (typeof rawName === "string" && rawName.trim()) {
91
+ const trimmed = rawName.trim();
92
+ const normalized = trimmed.toLowerCase();
93
+ if (
94
+ normalized === "assistant" ||
95
+ normalized === "agent" ||
96
+ normalized === "ai" ||
97
+ normalized === "model" ||
98
+ normalized === "{{agentname}}"
99
+ ) {
100
+ return fallbackAgentName;
101
+ }
102
+ if (
103
+ normalized === "user" ||
104
+ normalized === "human" ||
105
+ normalized === "{{user}}" ||
106
+ normalized === "customer"
107
+ ) {
108
+ return "{{user1}}";
109
+ }
110
+ return trimmed;
111
+ }
112
+ return fallbackMissingSpeaker ? fallbackAgentName : "";
113
+ }
114
+
115
+ function normalizeMessageText(raw: unknown): string {
116
+ if (typeof raw === "string") return raw.trim();
117
+ return "";
118
+ }
119
+
120
+ function normalizeConversation(
121
+ conversation: unknown,
122
+ fallbackAgentName: string,
123
+ options: { fallbackMissingSpeaker?: boolean } = {},
124
+ ): MessageExampleGroup | null {
125
+ const rawExamples = Array.isArray(conversation)
126
+ ? conversation
127
+ : conversation &&
128
+ typeof conversation === "object" &&
129
+ Array.isArray(
130
+ (conversation as { examples?: unknown[] }).examples,
131
+ )
132
+ ? (conversation as { examples: unknown[] }).examples
133
+ : null;
134
+
135
+ if (!rawExamples) return null;
136
+
137
+ const examples = rawExamples
138
+ .map((message) => {
139
+ const record =
140
+ message && typeof message === "object"
141
+ ? (message as Record<string, unknown>)
142
+ : null;
143
+ if (!record) return null;
144
+
145
+ const content =
146
+ record.content && typeof record.content === "object"
147
+ ? (record.content as Record<string, unknown>)
148
+ : null;
149
+ const text = normalizeMessageText(
150
+ content?.text ?? record.text ?? record.message ?? record.content,
151
+ );
152
+ if (!text) return null;
153
+
154
+ return {
155
+ name: normalizeSpeakerName(
156
+ record.name ?? record.user ?? record.speaker ?? record.role,
157
+ fallbackAgentName,
158
+ options,
159
+ ),
160
+ content: { text },
161
+ };
162
+ })
163
+ .filter(
164
+ (
165
+ message,
166
+ ): message is { name: string; content: { text: string } } =>
167
+ Boolean(message?.name && message.content.text),
168
+ );
169
+
170
+ if (examples.length === 0) return null;
171
+ return { examples };
172
+ }
173
+
174
+ export function normalizeGeneratedMessageExamples(
175
+ input: unknown,
176
+ fallbackAgentName = "Agent",
177
+ options: { fallbackMissingSpeaker?: boolean } = {},
178
+ ): MessageExampleGroup[] {
179
+ let parsed = input;
180
+
181
+ if (typeof input === "string") {
182
+ const candidate = extractLikelyJson(input);
183
+ try {
184
+ parsed = JSON.parse(candidate);
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+
190
+ const source =
191
+ parsed &&
192
+ typeof parsed === "object" &&
193
+ Array.isArray(
194
+ (parsed as { messageExamples?: unknown[] }).messageExamples,
195
+ )
196
+ ? (parsed as { messageExamples: unknown[] }).messageExamples
197
+ : parsed;
198
+
199
+ if (!Array.isArray(source)) return [];
200
+
201
+ const groups =
202
+ source.length > 0 &&
203
+ source.every(
204
+ (entry) =>
205
+ entry && typeof entry === "object" && Array.isArray((entry as { examples?: unknown[] }).examples),
206
+ )
207
+ ? source
208
+ : source.every((entry) => Array.isArray(entry))
209
+ ? source
210
+ : [source];
211
+
212
+ return groups
213
+ .map((group) => normalizeConversation(group, fallbackAgentName, options))
214
+ .filter((group): group is MessageExampleGroup => Boolean(group));
215
+ }
216
+
54
217
  export function prepareDraftForSave(
55
218
  draft: CharacterData,
56
219
  ): Record<string, unknown> {
@@ -91,24 +254,43 @@ export function prepareDraftForSave(
91
254
  );
92
255
  if (postExamples.length > 0) result.postExamples = postExamples;
93
256
 
94
- if (
95
- Array.isArray(draft.messageExamples) &&
96
- draft.messageExamples.length > 0
97
- ) {
257
+ if (draft.messageExamples != null) {
98
258
  // Strip extra fields from content (schema is .strict() — only text + actions allowed)
99
- const cleaned = draft.messageExamples
100
- .map((group) => ({
101
- examples: (group.examples ?? [])
102
- .filter((msg) => msg.name?.trim() && msg.content?.text?.trim())
103
- .map((msg) => ({
104
- name: msg.name,
259
+ const cleaned = normalizeGeneratedMessageExamples(
260
+ draft.messageExamples,
261
+ draft.name?.trim() || "Agent",
262
+ { fallbackMissingSpeaker: false },
263
+ ).map((group, groupIndex) => ({
264
+ examples: group.examples
265
+ .map((msg) => {
266
+ const originalGroup =
267
+ Array.isArray(draft.messageExamples) &&
268
+ draft.messageExamples[groupIndex] &&
269
+ typeof draft.messageExamples[groupIndex] === "object"
270
+ ? (draft.messageExamples[groupIndex] as {
271
+ examples?: Array<{
272
+ name?: string;
273
+ content?: { text?: string; actions?: string[] };
274
+ }>;
275
+ })
276
+ : null;
277
+ const originalMessage = originalGroup?.examples?.find(
278
+ (candidate) =>
279
+ candidate?.name?.trim() === msg.name &&
280
+ candidate?.content?.text?.trim() === msg.content.text,
281
+ );
282
+ return {
283
+ name: msg.name.trim(),
105
284
  content: {
106
- text: msg.content.text,
107
- ...(msg.content.actions ? { actions: msg.content.actions } : {}),
285
+ text: msg.content.text.trim(),
286
+ ...(originalMessage?.content?.actions
287
+ ? { actions: originalMessage.content.actions }
288
+ : {}),
108
289
  },
109
- })),
110
- }))
111
- .filter((group) => group.examples.length > 0);
290
+ };
291
+ })
292
+ .filter((msg) => msg.name && msg.content.text),
293
+ }));
112
294
  if (cleaned.length > 0) result.messageExamples = cleaned;
113
295
  }
114
296
 
@@ -13,11 +13,12 @@ import {
13
13
  dispatchWindowEvent,
14
14
  VOICE_CONFIG_UPDATED_EVENT,
15
15
  } from "@elizaos/app-core/events";
16
+ import { COMPANION_ENABLED } from "@elizaos/app-core/navigation";
17
+ import { normalizeGeneratedMessageExamples } from "../actions/character";
16
18
  import { useApp } from "@elizaos/app-core/state";
17
19
  import {
18
20
  CharacterRoster,
19
21
  type CharacterRosterEntry,
20
- CHARACTER_PRESET_META,
21
22
  resolveRosterEntries,
22
23
  } from "./CharacterRoster";
23
24
  import {
@@ -389,7 +390,7 @@ export function CharacterView({
389
390
  }, [onboardingOptions?.styles]);
390
391
 
391
392
  const characterRoster = resolveRosterEntries(rosterStyles);
392
- const visibleCharacterRoster = characterRoster.slice(0, 4);
393
+ const visibleCharacterRoster = characterRoster;
393
394
  const currentCharacter = hasCharacterContent(characterDraft)
394
395
  ? characterDraft
395
396
  : characterData;
@@ -533,12 +534,12 @@ export function CharacterView({
533
534
  dispatchWindowEvent(VOICE_CONFIG_UPDATED_EVENT, normalizedVoiceConfig);
534
535
  }, [voiceConfig]);
535
536
 
536
- const d = characterDraft;
537
+ const d = characterDraft ?? ({} as Record<string, unknown>);
537
538
  const bioText =
538
539
  typeof d.bio === "string"
539
540
  ? d.bio
540
541
  : Array.isArray(d.bio)
541
- ? d.bio.join("\n")
542
+ ? (d.bio as string[]).join("\n")
542
543
  : "";
543
544
 
544
545
  const getCharContext = useCallback(
@@ -598,23 +599,18 @@ export function CharacterView({
598
599
  /* raw text fallback */
599
600
  }
600
601
  } else if (field === "chatExamples") {
601
- try {
602
- const parsed = JSON.parse(generated);
603
- if (Array.isArray(parsed)) {
604
- const formatted = parsed.map(
605
- (
606
- convo: Array<{ user: string; content: { text: string } }>,
607
- ) => ({
608
- examples: convo.map((msg) => ({
609
- name: msg.user,
610
- content: { text: msg.content.text },
611
- })),
612
- }),
613
- );
614
- handleFieldEdit("messageExamples", formatted);
615
- }
616
- } catch {
617
- /* raw text fallback */
602
+ const formatted = normalizeGeneratedMessageExamples(
603
+ generated,
604
+ d.name?.trim() || "Agent",
605
+ );
606
+ if (formatted.length > 0) {
607
+ setState("characterSaveError", null);
608
+ handleFieldEdit("messageExamples", formatted);
609
+ } else {
610
+ setState(
611
+ "characterSaveError",
612
+ "Generated chat examples could not be parsed. Try again.",
613
+ );
618
614
  }
619
615
  } else if (field === "postExamples") {
620
616
  try {
@@ -633,8 +629,15 @@ export function CharacterView({
633
629
  /* raw text fallback */
634
630
  }
635
631
  }
636
- } catch {
637
- /* generation failed */
632
+ } catch (err) {
633
+ if (field === "chatExamples") {
634
+ setState(
635
+ "characterSaveError",
636
+ err instanceof Error
637
+ ? `Failed to generate chat examples: ${err.message}`
638
+ : "Failed to generate chat examples.",
639
+ );
640
+ }
638
641
  }
639
642
  setGenerating(null);
640
643
  },
@@ -644,6 +647,7 @@ export function CharacterView({
644
647
  handleFieldEdit,
645
648
  handleStyleEdit,
646
649
  handleCharacterArrayInput,
650
+ setState,
647
651
  ],
648
652
  );
649
653
 
@@ -802,19 +806,44 @@ export function CharacterView({
802
806
  ]);
803
807
 
804
808
  const handleSaveAll = useCallback(async () => {
805
- setVoiceSaving(true);
809
+ const trimmedName =
810
+ typeof characterDraft?.name === "string" ? characterDraft.name.trim() : "";
811
+ if (!trimmedName) {
812
+ setVoiceSaveError(null);
813
+ setState("characterSaveSuccess", null);
814
+ setState("characterSaveError", "Character name is required before saving.");
815
+ return;
816
+ }
817
+
806
818
  setVoiceSaveError(null);
819
+ try {
820
+ await handleSaveCharacter();
821
+ } catch {
822
+ return;
823
+ }
824
+
825
+ setVoiceSaving(true);
826
+ let voiceSaveFailed = false;
807
827
  try {
808
828
  await persistVoiceConfig();
809
829
  } catch (err) {
830
+ voiceSaveFailed = true;
810
831
  setVoiceSaveError(
811
832
  err instanceof Error ? err.message : "Failed to save voice settings.",
812
833
  );
813
834
  } finally {
814
835
  setVoiceSaving(false);
815
836
  }
816
- await handleSaveCharacter();
817
- }, [handleSaveCharacter, persistVoiceConfig]);
837
+ if (!voiceSaveFailed) {
838
+ setTab(COMPANION_ENABLED ? "companion" : "chat");
839
+ }
840
+ }, [
841
+ characterDraft?.name,
842
+ handleSaveCharacter,
843
+ persistVoiceConfig,
844
+ setState,
845
+ setTab,
846
+ ]);
818
847
 
819
848
  /* ── Helpers ────────────────────────────────────────────────────── */
820
849
  const cardCls = sceneOverlay
@@ -839,8 +868,8 @@ export function CharacterView({
839
868
  const notebookShellCls =
840
869
  "relative isolate flex h-[34.75rem] w-full max-w-[29rem] overflow-visible";
841
870
  const notebookFrameCls = sceneOverlay
842
- ? "relative flex h-full w-full overflow-hidden rounded-[1.75rem] border border-[#2f261b]/20 bg-[#f8f0df]/70 shadow-[0_24px_60px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.5)]"
843
- : "relative flex h-full w-full overflow-hidden rounded-[1.75rem] border border-[#2f261b]/14 bg-[#f8f0df] shadow-[0_26px_60px_rgba(36,28,18,0.2),inset_0_1px_0_rgba(255,255,255,0.58)]";
871
+ ? "relative flex h-full w-full overflow-hidden rounded-[1.75rem] border border-[#2f261b]/20 bg-[#f8f0df]/70 dark:border-white/10 dark:bg-[#1c1c1e]/90 shadow-[0_24px_60px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.5)]"
872
+ : "relative flex h-full w-full overflow-hidden rounded-[1.75rem] border border-[#2f261b]/14 bg-[#f8f0df] dark:border-white/10 dark:bg-[#1c1c1e] shadow-[0_26px_60px_rgba(36,28,18,0.2),inset_0_1px_0_rgba(255,255,255,0.58)]";
844
873
 
845
874
  const handleCustomVrmUpload = useCallback(
846
875
  (file: File) => {
@@ -872,7 +901,11 @@ export function CharacterView({
872
901
  PREMADE_VOICES.find((preset) => preset.id === selectedVoicePresetId) ??
873
902
  null;
874
903
  const voiceSelectValue = selectedVoicePresetId ?? null;
875
- const combinedSaveError = voiceSaveError ?? characterSaveError;
904
+ const combinedSaveError = characterSaveError;
905
+ const saveAdvisory =
906
+ characterSaveSuccess && voiceSaveError
907
+ ? `Character saved. Voice settings still need attention: ${voiceSaveError}`
908
+ : null;
876
909
  const customizationActionLabel = customOverridesEnabled
877
910
  ? t("characterview.backToCharacterSelect")
878
911
  : t("characterview.customize");
@@ -995,16 +1028,16 @@ export function CharacterView({
995
1028
  className={notebookShellCls}
996
1029
  data-testid="character-notebook"
997
1030
  >
998
- <div className="pointer-events-none absolute inset-x-5 bottom-[-1.2rem] top-6 rounded-[1.9rem] bg-[linear-gradient(180deg,rgba(35,27,18,0.12)_0%,rgba(16,12,9,0.32)_100%)] blur-xl" />
999
- <div className="pointer-events-none absolute inset-[0.35rem] translate-x-2 translate-y-2 rounded-[1.9rem] border border-[#d7c5a4]/50 bg-[linear-gradient(180deg,rgba(251,246,235,0.86)_0%,rgba(236,224,198,0.7)_100%)] shadow-[0_12px_26px_rgba(62,44,21,0.12)]" />
1031
+ <div className="pointer-events-none absolute inset-x-5 bottom-[-1.2rem] top-6 rounded-[1.9rem] bg-[linear-gradient(180deg,rgba(35,27,18,0.12)_0%,rgba(16,12,9,0.32)_100%)] dark:bg-[linear-gradient(180deg,rgba(0,0,0,0.3)_0%,rgba(0,0,0,0.5)_100%)] blur-xl" />
1032
+ <div className="pointer-events-none absolute inset-[0.35rem] translate-x-2 translate-y-2 rounded-[1.9rem] border border-[#d7c5a4]/50 bg-[linear-gradient(180deg,rgba(251,246,235,0.86)_0%,rgba(236,224,198,0.7)_100%)] dark:border-white/10 dark:bg-[linear-gradient(180deg,rgba(38,38,40,0.9)_0%,rgba(28,28,30,0.8)_100%)] shadow-[0_12px_26px_rgba(62,44,21,0.12)]" />
1000
1033
  <div className="pointer-events-none absolute bottom-[-0.95rem] left-8 h-14 w-4 rounded-b-sm bg-[linear-gradient(180deg,#ddb45e_0%,#be8530_100%)] [clip-path:polygon(0_0,100%_0,100%_78%,50%_100%,0_78%)] shadow-[0_10px_20px_rgba(141,97,34,0.28)]" />
1001
1034
  <div className={notebookFrameCls}>
1002
- <div className="pointer-events-none absolute left-4 top-4 h-2 w-2 rounded-full border border-[#d8c295]/70 bg-[#fff9eb]/85 shadow-[0_0_0_2px_rgba(150,116,61,0.08)]" />
1003
- <div className="pointer-events-none absolute bottom-4 left-4 h-2 w-2 rounded-full border border-[#d8c295]/70 bg-[#fff9eb]/85 shadow-[0_0_0_2px_rgba(150,116,61,0.08)]" />
1035
+ <div className="pointer-events-none absolute left-4 top-4 h-2 w-2 rounded-full border border-[#d8c295]/70 bg-[#fff9eb]/85 shadow-[0_0_0_2px_rgba(150,116,61,0.08)] dark:border-white/20 dark:bg-white/10 dark:shadow-[0_0_0_2px_rgba(255,255,255,0.05)]" />
1036
+ <div className="pointer-events-none absolute bottom-4 left-4 h-2 w-2 rounded-full border border-[#d8c295]/70 bg-[#fff9eb]/85 shadow-[0_0_0_2px_rgba(150,116,61,0.08)] dark:border-white/20 dark:bg-white/10 dark:shadow-[0_0_0_2px_rgba(255,255,255,0.05)]" />
1004
1037
  <div className="pointer-events-none absolute right-4 top-4 h-2 w-2 rounded-full border border-white/10 bg-white/10 shadow-[0_0_0_2px_rgba(255,255,255,0.03)]" />
1005
1038
  <div className="pointer-events-none absolute bottom-4 right-4 h-2 w-2 rounded-full border border-white/10 bg-white/10 shadow-[0_0_0_2px_rgba(255,255,255,0.03)]" />
1006
- <div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,rgba(104,77,39,0.07)_100%)]" />
1007
- <div className="pointer-events-none absolute bottom-3 right-[4.95rem] top-3 w-[0.7rem] rounded-full bg-[linear-gradient(90deg,rgba(116,92,55,0.16)_0%,rgba(255,255,255,0.5)_45%,rgba(112,88,52,0.2)_100%)] shadow-[inset_0_0_7px_rgba(89,67,37,0.16),0_0_18px_rgba(255,246,220,0.16)]" />
1039
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,rgba(104,77,39,0.07)_100%)] dark:bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,rgba(0,0,0,0.15)_100%)]" />
1040
+ <div className="pointer-events-none absolute bottom-3 right-[4.95rem] top-3 w-[0.7rem] rounded-full bg-[linear-gradient(90deg,rgba(116,92,55,0.16)_0%,rgba(255,255,255,0.5)_45%,rgba(112,88,52,0.2)_100%)] shadow-[inset_0_0_7px_rgba(89,67,37,0.16),0_0_18px_rgba(255,246,220,0.16)] dark:bg-[linear-gradient(90deg,rgba(255,255,255,0.05)_0%,rgba(255,255,255,0.15)_45%,rgba(255,255,255,0.05)_100%)] dark:shadow-[inset_0_0_7px_rgba(0,0,0,0.3),0_0_18px_rgba(255,255,255,0.05)]" />
1008
1041
  {/* ── Book page (left) ── */}
1009
1042
  <div
1010
1043
  className={`${bookPageCls} flex flex-1 flex-col rounded-l-[1.75rem] border-r border-[#cdbb98]/60 text-[#1e2329] dark:border-white/[0.08] dark:text-[hsl(40,10%,84%)]`}
@@ -1411,13 +1444,18 @@ export function CharacterView({
1411
1444
  )}
1412
1445
 
1413
1446
  <div className={`${sectionCls} relative z-10 ${sceneOverlay ? "character-action-bar" : ""}`}>
1414
- {(characterSaveSuccess || combinedSaveError) && (
1447
+ {(characterSaveSuccess || combinedSaveError || saveAdvisory) && (
1415
1448
  <div className="mb-3 flex flex-wrap items-center justify-center gap-2">
1416
1449
  {characterSaveSuccess && (
1417
1450
  <span className="rounded-lg border border-green-400/20 bg-green-400/10 px-3 py-1.5 text-xs font-bold text-green-400">
1418
1451
  {characterSaveSuccess}
1419
1452
  </span>
1420
1453
  )}
1454
+ {saveAdvisory && (
1455
+ <span className="rounded-lg border border-amber-400/20 bg-amber-400/10 px-3 py-1.5 text-xs font-medium text-amber-300">
1456
+ {saveAdvisory}
1457
+ </span>
1458
+ )}
1421
1459
  {combinedSaveError && (
1422
1460
  <span className="rounded-lg border border-danger/20 bg-danger/10 px-3 py-1.5 text-xs font-medium text-danger">
1423
1461
  {combinedSaveError}
@@ -1426,8 +1464,8 @@ export function CharacterView({
1426
1464
  </div>
1427
1465
  )}
1428
1466
 
1429
- <div className="relative flex flex-col gap-3 md:min-h-10 md:flex-row md:items-center md:justify-between">
1430
- <div className="flex items-center justify-center md:justify-start">
1467
+ <div className="flex min-h-10 flex-row items-center justify-between gap-2">
1468
+ <div className="flex items-center justify-start">
1431
1469
  <div
1432
1470
  className="flex min-w-0 items-center gap-2"
1433
1471
  data-testid="character-voice-picker"
@@ -1466,7 +1504,7 @@ export function CharacterView({
1466
1504
  }}
1467
1505
  placeholder={t("characterview.selectAVoice")}
1468
1506
  menuPlacement="top"
1469
- className="w-[11rem] max-w-[58vw]"
1507
+ className="min-w-0 w-[7rem] sm:w-[11rem]"
1470
1508
  triggerClassName="h-8 rounded-full border-border/50 bg-bg/65 px-4 py-0 text-[11px] shadow-inner backdrop-blur-sm"
1471
1509
  menuClassName="border-border/60 bg-bg/92 shadow-2xl backdrop-blur-md"
1472
1510
  />
@@ -1495,25 +1533,32 @@ export function CharacterView({
1495
1533
  </div>
1496
1534
  </div>
1497
1535
 
1498
- <div className="flex items-center justify-center md:absolute md:left-1/2 md:top-1/2 md:z-10 md:-translate-x-1/2 md:-translate-y-1/2">
1536
+ <div className="flex items-center">
1499
1537
  <Button
1500
1538
  size="lg"
1501
- className="rounded-xl px-8 text-[13px] font-bold tracking-wider shadow-[0_0_15px_color-mix(in_srgb,var(--accent)_20%,transparent)] transition-all hover:shadow-[0_0_20px_color-mix(in_srgb,var(--accent)_40%,transparent)]"
1539
+ className="ce-save-btn"
1502
1540
  disabled={characterSaving || voiceSaving}
1503
1541
  onClick={() => void handleSaveAll()}
1504
1542
  >
1505
- {characterSaving || voiceSaving ? "saving..." : "Save Character"}
1543
+ {characterSaving || voiceSaving ? (
1544
+ "saving..."
1545
+ ) : (
1546
+ <>
1547
+ <span className="hidden sm:inline">Save Character</span>
1548
+ <span className="sm:hidden">Save</span>
1549
+ </>
1550
+ )}
1506
1551
  </Button>
1507
1552
  </div>
1508
1553
 
1509
- <div className="flex items-center justify-center md:justify-end">
1554
+ <div className="flex items-center justify-end">
1510
1555
  <Button
1511
1556
  type="button"
1512
1557
  variant={customOverridesEnabled ? "outline" : "default"}
1513
1558
  size="sm"
1514
- className={`h-10 rounded-xl px-4 text-sm font-semibold ${customOverridesEnabled
1559
+ className={`ce-customize-btn ${customOverridesEnabled
1515
1560
  ? "border-border/40 bg-bg/40 text-txt"
1516
- : "shadow-[0_0_18px_color-mix(in_srgb,var(--accent)_18%,transparent)]"
1561
+ : ""
1517
1562
  }`}
1518
1563
  onClick={() =>
1519
1564
  handleCustomOverridesChange(!customOverridesEnabled)
@@ -0,0 +1,61 @@
1
+ // @vitest-environment jsdom
2
+ import React from "react";
3
+ import TestRenderer from "react-test-renderer";
4
+ import { describe, expect, it } from "vitest";
5
+ import { FlaminaGuideCard } from "./FlaminaGuide";
6
+
7
+ function textOf(node: TestRenderer.ReactTestInstance): string {
8
+ return node.children
9
+ .map((child) => (typeof child === "string" ? child : textOf(child)))
10
+ .join("");
11
+ }
12
+
13
+ describe("FlaminaGuideCard", () => {
14
+ it("explains provider impact on character behavior", () => {
15
+ const tree = TestRenderer.create(
16
+ React.createElement(FlaminaGuideCard, { topic: "provider" }),
17
+ );
18
+
19
+ const renderedText = textOf(tree.root);
20
+
21
+ expect(renderedText).toContain("reasons");
22
+ expect(renderedText).toContain("latency");
23
+ expect(renderedText).toContain("output quality");
24
+ });
25
+
26
+ it("explains rpc impact on external capabilities", () => {
27
+ const tree = TestRenderer.create(
28
+ React.createElement(FlaminaGuideCard, { topic: "rpc" }),
29
+ );
30
+
31
+ const renderedText = textOf(tree.root);
32
+
33
+ expect(renderedText).toContain("wallets");
34
+ expect(renderedText).toContain("chains");
35
+ expect(renderedText).toContain("external execution");
36
+ });
37
+
38
+ it("explains permissions impact on local access", () => {
39
+ const tree = TestRenderer.create(
40
+ React.createElement(FlaminaGuideCard, { topic: "permissions" }),
41
+ );
42
+
43
+ const renderedText = textOf(tree.root);
44
+
45
+ expect(renderedText).toContain("see");
46
+ expect(renderedText).toContain("control");
47
+ expect(renderedText).toContain("locally");
48
+ });
49
+
50
+ it("explains voice impact on presentation", () => {
51
+ const tree = TestRenderer.create(
52
+ React.createElement(FlaminaGuideCard, { topic: "voice" }),
53
+ );
54
+
55
+ const renderedText = textOf(tree.root);
56
+
57
+ expect(renderedText).toContain("sounds");
58
+ expect(renderedText).toContain("spoken interactions");
59
+ expect(renderedText).toContain("saved");
60
+ });
61
+ });