arkaos 2.94.0 → 2.96.0

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.
@@ -0,0 +1,691 @@
1
+ <script setup lang="ts">
2
+ // PR78 v2.96.0 — Persona detail page.
3
+ //
4
+ // Mirror of agents/[id].vue: gradient hero + initials avatar + stats
5
+ // row + UTabs (DNA / Communication / Knowledge / Linked Agents).
6
+ // Edit drawer reused from PR74 (PersonaDetailDrawer's edit form)
7
+ // but slimmed: the page itself is the read view; the drawer only
8
+ // flips into edit mode.
9
+ //
10
+ // Replaces the previous drawer-everywhere UX with the page-per-record
11
+ // pattern that already shipped for /agents.
12
+
13
+ import type { Persona } from '~/types'
14
+
15
+ interface DetailResponse extends Persona {
16
+ _source_store?: 'obsidian' | 'json'
17
+ _obsidian_path?: string
18
+ }
19
+
20
+ const route = useRoute()
21
+ const personaId = route.params.id as string
22
+
23
+ const { fetchApi, apiBase } = useApi()
24
+ const toast = useToast()
25
+ const confirmDialog = useConfirmDialog()
26
+
27
+ const { data: detail, status, error, refresh } = fetchApi<DetailResponse>(`/api/personas/${personaId}`)
28
+
29
+ const { data: usageData } = fetchApi<{
30
+ by_persona: Record<string, { agent_count: number, agent_ids: string[] }>
31
+ }>('/api/personas/usage')
32
+
33
+ const linkedAgentIds = computed<string[]>(() =>
34
+ usageData.value?.by_persona?.[personaId]?.agent_ids ?? [],
35
+ )
36
+ const linkedAgentCount = computed(() => linkedAgentIds.value.length)
37
+
38
+ // ─── Hero helpers (matching personas/index.vue + agents/[id].vue) ───────
39
+
40
+ function heroInitials(name: string | undefined): string {
41
+ if (!name) return '·'
42
+ const parts = name.trim().split(/\s+/)
43
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
44
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
45
+ }
46
+
47
+ function mbtiGradientClass(mbti: string | undefined): string {
48
+ if (!mbti) return 'bg-gradient-to-br from-muted/20 to-muted/5'
49
+ const code = mbti.toUpperCase()
50
+ if (['INTJ', 'INTP', 'ENTJ', 'ENTP'].includes(code))
51
+ return 'bg-gradient-to-br from-blue-500/30 to-indigo-600/10'
52
+ if (['INFJ', 'INFP', 'ENFJ', 'ENFP'].includes(code))
53
+ return 'bg-gradient-to-br from-emerald-500/30 to-teal-600/10'
54
+ if (['ISTJ', 'ISFJ', 'ESTJ', 'ESFJ'].includes(code))
55
+ return 'bg-gradient-to-br from-amber-500/30 to-orange-600/10'
56
+ if (['ISTP', 'ISFP', 'ESTP', 'ESFP'].includes(code))
57
+ return 'bg-gradient-to-br from-rose-500/30 to-pink-600/10'
58
+ return 'bg-gradient-to-br from-primary/20 to-primary/5'
59
+ }
60
+
61
+ const mbtiDescriptions: Record<string, string> = {
62
+ INTJ: 'Ni-Te-Fi-Se — The Architect',
63
+ INTP: 'Ti-Ne-Si-Fe — The Logician',
64
+ ENTJ: 'Te-Ni-Se-Fi — The Commander',
65
+ ENTP: 'Ne-Ti-Fe-Si — The Debater',
66
+ INFJ: 'Ni-Fe-Ti-Se — The Advocate',
67
+ INFP: 'Fi-Ne-Si-Te — The Mediator',
68
+ ENFJ: 'Fe-Ni-Se-Ti — The Protagonist',
69
+ ENFP: 'Ne-Fi-Te-Si — The Campaigner',
70
+ ISTJ: 'Si-Te-Fi-Ne — The Inspector',
71
+ ISFJ: 'Si-Fe-Ti-Ne — The Defender',
72
+ ESTJ: 'Te-Si-Ne-Fi — The Executive',
73
+ ESFJ: 'Fe-Si-Ne-Ti — The Consul',
74
+ ISTP: 'Ti-Se-Ni-Fe — The Virtuoso',
75
+ ISFP: 'Fi-Se-Ni-Te — The Adventurer',
76
+ ESTP: 'Se-Ti-Fe-Ni — The Entrepreneur',
77
+ ESFP: 'Se-Fi-Te-Ni — The Entertainer',
78
+ }
79
+
80
+ const discLetters = ['D', 'I', 'S', 'C'] as const
81
+ const bigFiveKeys = ['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const
82
+ const bigFiveLabels: Record<string, string> = {
83
+ openness: 'Openness',
84
+ conscientiousness: 'Conscientiousness',
85
+ extraversion: 'Extraversion',
86
+ agreeableness: 'Agreeableness',
87
+ neuroticism: 'Neuroticism',
88
+ }
89
+
90
+ function discBarValue(letter: string): number {
91
+ if (!detail.value?.disc) return 20
92
+ if (detail.value.disc.primary === letter) return 90
93
+ if (detail.value.disc.secondary === letter) return 70
94
+ return 20
95
+ }
96
+
97
+ function discBarColor(letter: string): string {
98
+ const colors: Record<string, string> = {
99
+ D: 'bg-red-500', I: 'bg-yellow-500', S: 'bg-green-500', C: 'bg-blue-500',
100
+ }
101
+ return colors[letter] ?? 'bg-primary'
102
+ }
103
+
104
+ function bigFiveBarColor(value: number): string {
105
+ if (value >= 75) return 'bg-primary'
106
+ if (value >= 50) return 'bg-blue-400'
107
+ if (value >= 30) return 'bg-yellow-500'
108
+ return 'bg-neutral-500'
109
+ }
110
+
111
+ // ─── Tabs ───────────────────────────────────────────────────────────────
112
+
113
+ const tabs = [
114
+ { label: 'DNA', value: 'dna', icon: 'i-lucide-dna' },
115
+ { label: 'Communication', value: 'communication', icon: 'i-lucide-message-square' },
116
+ { label: 'Knowledge', value: 'knowledge', icon: 'i-lucide-brain' },
117
+ { label: 'Linked Agents', value: 'agents', icon: 'i-lucide-users' },
118
+ ]
119
+
120
+ // ─── Edit drawer state ─────────────────────────────────────────────────
121
+
122
+ const editOpen = ref(false)
123
+ const draft = ref<Persona | null>(null)
124
+ const saving = ref(false)
125
+ const dirty = ref(false)
126
+
127
+ function startEdit() {
128
+ if (!detail.value) return
129
+ draft.value = JSON.parse(JSON.stringify(detail.value)) as Persona
130
+ dirty.value = false
131
+ editOpen.value = true
132
+ }
133
+
134
+ function markDirty() { dirty.value = true }
135
+
136
+ async function tryCloseEdit() {
137
+ if (dirty.value && !saving.value) {
138
+ const ok = await confirmDialog({
139
+ title: 'Discard unsaved edits?',
140
+ description: 'Any changes you made will be lost.',
141
+ confirmLabel: 'Discard',
142
+ cancelLabel: 'Keep editing',
143
+ variant: 'danger',
144
+ })
145
+ if (!ok) return
146
+ }
147
+ editOpen.value = false
148
+ draft.value = null
149
+ }
150
+
151
+ async function saveEdit() {
152
+ if (!draft.value) return
153
+ saving.value = true
154
+ try {
155
+ const res = await $fetch<{
156
+ id: string
157
+ updated: boolean
158
+ json_written: boolean
159
+ obsidian_path: string | null
160
+ error?: string
161
+ }>(`${apiBase}/api/personas/${personaId}`, {
162
+ method: 'PUT',
163
+ body: draft.value,
164
+ })
165
+ if (res.error) throw new Error(res.error)
166
+ toast.add({
167
+ title: 'Persona saved',
168
+ description: res.obsidian_path
169
+ ? `Wrote ${res.obsidian_path.split('/').slice(-2).join('/')}`
170
+ : 'Saved to JSON store',
171
+ color: 'success',
172
+ })
173
+ await refresh()
174
+ editOpen.value = false
175
+ draft.value = null
176
+ dirty.value = false
177
+ } catch (err) {
178
+ toast.add({
179
+ title: 'Save failed',
180
+ description: err instanceof Error ? err.message : 'unknown error',
181
+ color: 'error',
182
+ })
183
+ } finally {
184
+ saving.value = false
185
+ }
186
+ }
187
+
188
+ async function deletePersona() {
189
+ if (!detail.value) return
190
+ const ok = await confirmDialog({
191
+ title: `Delete persona "${detail.value.name}"?`,
192
+ description:
193
+ 'Removes it from the JSON store. The Obsidian file (if any) is '
194
+ + 'left in place — delete manually from Obsidian if you want it gone.',
195
+ confirmLabel: 'Delete persona',
196
+ variant: 'danger',
197
+ })
198
+ if (!ok) return
199
+ try {
200
+ await $fetch(`${apiBase}/api/personas/${personaId}`, { method: 'DELETE' })
201
+ toast.add({
202
+ title: 'Persona deleted',
203
+ description: detail.value.name,
204
+ color: 'success',
205
+ })
206
+ await navigateTo('/personas')
207
+ } catch (err) {
208
+ toast.add({
209
+ title: 'Delete failed',
210
+ description: err instanceof Error ? err.message : 'unknown error',
211
+ color: 'error',
212
+ })
213
+ }
214
+ }
215
+
216
+ function listToCsv(list: string[] | undefined): string {
217
+ return (list ?? []).join(', ')
218
+ }
219
+
220
+ function csvToList(value: string): string[] {
221
+ return value.split(',').map((s) => s.trim()).filter(Boolean)
222
+ }
223
+
224
+ const mbtiOptions = [
225
+ 'INTJ', 'INTP', 'ENTJ', 'ENTP',
226
+ 'INFJ', 'INFP', 'ENFJ', 'ENFP',
227
+ 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
228
+ 'ISTP', 'ISFP', 'ESTP', 'ESFP',
229
+ ].map((t) => ({ label: t, value: t }))
230
+
231
+ const discOptions = [
232
+ { label: 'D — Dominance', value: 'D' },
233
+ { label: 'I — Influence', value: 'I' },
234
+ { label: 'S — Steadiness', value: 'S' },
235
+ { label: 'C — Conscientiousness', value: 'C' },
236
+ ]
237
+
238
+ const vocabOptions = [
239
+ { label: 'Lay (no jargon)', value: 'lay' },
240
+ { label: 'Specialist (industry terms)', value: 'specialist' },
241
+ { label: 'Expert (research-level)', value: 'expert' },
242
+ ]
243
+ </script>
244
+
245
+ <template>
246
+ <UDashboardPanel id="persona-detail">
247
+ <template #header>
248
+ <UDashboardNavbar :title="detail?.name ?? 'Persona'">
249
+ <template #leading>
250
+ <UDashboardSidebarCollapse />
251
+ </template>
252
+ <template #trailing>
253
+ <UButton
254
+ label="Back"
255
+ variant="ghost"
256
+ icon="i-lucide-arrow-left"
257
+ to="/personas"
258
+ aria-label="Back to personas list"
259
+ />
260
+ </template>
261
+ </UDashboardNavbar>
262
+ </template>
263
+
264
+ <template #body>
265
+ <DashboardState
266
+ :status="status"
267
+ :error="error"
268
+ :empty="!detail"
269
+ empty-title="Persona not found"
270
+ empty-icon="i-lucide-user-x"
271
+ loading-label="Loading persona"
272
+ :on-retry="() => refresh()"
273
+ >
274
+ <div v-if="detail" class="space-y-6 pb-12">
275
+ <!-- HERO -->
276
+ <section
277
+ class="relative overflow-hidden rounded-2xl border border-default p-6 md:p-8"
278
+ :class="mbtiGradientClass(detail.mbti)"
279
+ >
280
+ <div class="flex items-start gap-5">
281
+ <div class="shrink-0 size-20 rounded-2xl bg-default/80 border border-default flex items-center justify-center shadow-lg backdrop-blur-sm">
282
+ <span class="text-2xl font-bold tracking-tight text-highlighted">
283
+ {{ heroInitials(detail.name) }}
284
+ </span>
285
+ </div>
286
+ <div class="flex-1 min-w-0 space-y-2">
287
+ <div class="flex items-start justify-between gap-3 flex-wrap">
288
+ <div class="min-w-0">
289
+ <h1 class="text-3xl md:text-4xl font-bold tracking-tight text-highlighted">
290
+ {{ detail.name }}
291
+ </h1>
292
+ <p v-if="detail.title" class="text-base md:text-lg text-muted mt-0.5">
293
+ {{ detail.title }}
294
+ </p>
295
+ </div>
296
+ <div class="flex items-center gap-2">
297
+ <UButton label="Edit" icon="i-lucide-pencil" size="sm" @click="startEdit" />
298
+ <UButton
299
+ icon="i-lucide-trash-2"
300
+ color="error"
301
+ variant="ghost"
302
+ size="sm"
303
+ aria-label="Delete persona"
304
+ @click="deletePersona"
305
+ />
306
+ </div>
307
+ </div>
308
+ <p v-if="detail.tagline" class="text-sm italic text-muted">
309
+ "{{ detail.tagline }}"
310
+ </p>
311
+ <div class="flex flex-wrap items-center gap-2 pt-1">
312
+ <UBadge
313
+ v-if="detail._source_store === 'obsidian'"
314
+ label="From Obsidian"
315
+ icon="i-lucide-file-text"
316
+ color="primary"
317
+ variant="subtle"
318
+ size="xs"
319
+ />
320
+ <UBadge
321
+ v-else-if="detail._source_store === 'json'"
322
+ label="JSON store"
323
+ variant="outline"
324
+ size="xs"
325
+ />
326
+ <UBadge v-if="detail.mbti" :label="detail.mbti" variant="soft" size="xs" />
327
+ <UBadge
328
+ v-if="detail.disc?.primary"
329
+ :label="`DISC: ${detail.disc.primary}${detail.disc.secondary ? '/' + detail.disc.secondary : ''}`"
330
+ variant="subtle"
331
+ size="xs"
332
+ />
333
+ </div>
334
+ <p
335
+ v-if="detail._obsidian_path"
336
+ class="text-[10px] text-muted/70 font-mono truncate mt-2"
337
+ :title="detail._obsidian_path"
338
+ >
339
+ {{ detail._obsidian_path.split('/').slice(-2).join('/') }}
340
+ </p>
341
+ </div>
342
+ </div>
343
+ </section>
344
+
345
+ <!-- STATS -->
346
+ <section class="grid grid-cols-2 md:grid-cols-4 gap-3">
347
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
348
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Linked agents</p>
349
+ <p class="text-2xl font-bold">{{ linkedAgentCount }}</p>
350
+ </div>
351
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
352
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Mental models</p>
353
+ <p class="text-2xl font-bold">{{ detail.mental_models?.length ?? 0 }}</p>
354
+ </div>
355
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
356
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Expertise domains</p>
357
+ <p class="text-2xl font-bold">{{ detail.expertise_domains?.length ?? 0 }}</p>
358
+ </div>
359
+ <div class="rounded-xl border border-default p-4 bg-elevated/20">
360
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Frameworks</p>
361
+ <p class="text-2xl font-bold">{{ detail.frameworks?.length ?? 0 }}</p>
362
+ </div>
363
+ </section>
364
+
365
+ <!-- TABS -->
366
+ <UTabs :items="tabs" default-value="dna" class="w-full">
367
+ <template #content="{ item }">
368
+ <!-- DNA -->
369
+ <div v-if="item.value === 'dna'" class="space-y-6 mt-6">
370
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
371
+ <UCard>
372
+ <div class="space-y-2">
373
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">MBTI</p>
374
+ <p class="text-4xl font-bold font-mono tracking-widest">
375
+ {{ detail.mbti || '----' }}
376
+ </p>
377
+ <p v-if="detail.mbti && mbtiDescriptions[detail.mbti]" class="text-sm text-muted">
378
+ {{ mbtiDescriptions[detail.mbti] }}
379
+ </p>
380
+ </div>
381
+ </UCard>
382
+
383
+ <UCard>
384
+ <div class="space-y-2">
385
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">Enneagram</p>
386
+ <p class="text-3xl font-bold">
387
+ Type {{ detail.enneagram?.type ?? '-' }}
388
+ <span v-if="detail.enneagram?.wing" class="text-xl font-normal text-muted">
389
+ w{{ detail.enneagram.wing }}
390
+ </span>
391
+ </p>
392
+ </div>
393
+ </UCard>
394
+
395
+ <UCard>
396
+ <div class="space-y-3">
397
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">DISC</p>
398
+ <p class="text-3xl font-bold font-mono">
399
+ {{ detail.disc?.primary ?? '' }}{{ detail.disc?.secondary ?? '' }}
400
+ </p>
401
+ <div class="space-y-2 pt-1">
402
+ <div v-for="letter in discLetters" :key="letter" class="flex items-center gap-2">
403
+ <span class="w-4 text-xs font-mono font-bold text-muted">{{ letter }}</span>
404
+ <div class="flex-1 h-2 rounded-full bg-muted/20">
405
+ <div
406
+ class="h-2 rounded-full"
407
+ :class="discBarColor(letter)"
408
+ :style="{ width: `${discBarValue(letter)}%` }"
409
+ />
410
+ </div>
411
+ <span class="w-6 text-right text-xs font-mono text-muted">
412
+ {{ discBarValue(letter) }}
413
+ </span>
414
+ </div>
415
+ </div>
416
+ </div>
417
+ </UCard>
418
+ </div>
419
+
420
+ <UCard>
421
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-4">
422
+ Big Five (OCEAN)
423
+ </p>
424
+ <div v-if="detail.big_five" class="space-y-3">
425
+ <div v-for="key in bigFiveKeys" :key="key" class="flex items-center gap-3">
426
+ <span class="w-36 text-sm text-muted">{{ bigFiveLabels[key] }}</span>
427
+ <div class="flex-1 h-2 rounded-full bg-muted/20">
428
+ <div
429
+ class="h-2 rounded-full"
430
+ :class="bigFiveBarColor((detail.big_five as any)[key] ?? 0)"
431
+ :style="{ width: `${(detail.big_five as any)[key] ?? 0}%` }"
432
+ />
433
+ </div>
434
+ <span class="w-8 text-right text-sm font-mono">
435
+ {{ (detail.big_five as any)[key] ?? 0 }}
436
+ </span>
437
+ </div>
438
+ </div>
439
+ </UCard>
440
+ </div>
441
+
442
+ <!-- COMMUNICATION -->
443
+ <div v-else-if="item.value === 'communication'" class="space-y-4 mt-6">
444
+ <UCard v-if="detail.communication">
445
+ <dl class="grid grid-cols-3 gap-2 text-sm">
446
+ <dt class="text-muted">Tone</dt>
447
+ <dd class="col-span-2">{{ detail.communication.tone || '—' }}</dd>
448
+ <dt class="text-muted">Vocabulary</dt>
449
+ <dd class="col-span-2">{{ detail.communication.vocabulary_level || '—' }}</dd>
450
+ </dl>
451
+ </UCard>
452
+ <div v-else class="py-8 text-center text-sm text-muted">
453
+ No communication data available.
454
+ </div>
455
+ </div>
456
+
457
+ <!-- KNOWLEDGE -->
458
+ <div v-else-if="item.value === 'knowledge'" class="space-y-4 mt-6">
459
+ <UCard v-if="detail.mental_models?.length">
460
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
461
+ Mental models ({{ detail.mental_models.length }})
462
+ </p>
463
+ <div class="flex flex-wrap gap-1.5">
464
+ <UBadge v-for="m in detail.mental_models" :key="m" :label="m" variant="outline" size="xs" />
465
+ </div>
466
+ </UCard>
467
+
468
+ <UCard v-if="detail.expertise_domains?.length">
469
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
470
+ Expertise ({{ detail.expertise_domains.length }})
471
+ </p>
472
+ <div class="flex flex-wrap gap-1.5">
473
+ <UBadge v-for="e in detail.expertise_domains" :key="e" :label="e" variant="soft" size="xs" />
474
+ </div>
475
+ </UCard>
476
+
477
+ <UCard v-if="detail.frameworks?.length">
478
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
479
+ Frameworks ({{ detail.frameworks.length }})
480
+ </p>
481
+ <ul class="space-y-2">
482
+ <li v-for="f in detail.frameworks" :key="f" class="flex items-center gap-2 text-sm">
483
+ <UIcon name="i-lucide-check" class="size-3.5 text-primary shrink-0" />
484
+ {{ f }}
485
+ </li>
486
+ </ul>
487
+ </UCard>
488
+
489
+ <UCard v-if="detail.key_quotes?.length">
490
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
491
+ Key quotes ({{ detail.key_quotes.length }})
492
+ </p>
493
+ <ul class="space-y-2">
494
+ <li
495
+ v-for="q in detail.key_quotes"
496
+ :key="q"
497
+ class="text-sm italic text-muted border-l-2 border-primary/30 pl-3"
498
+ >
499
+ "{{ q }}"
500
+ </li>
501
+ </ul>
502
+ </UCard>
503
+
504
+ <div
505
+ v-if="!detail.mental_models?.length && !detail.expertise_domains?.length && !detail.frameworks?.length && !detail.key_quotes?.length"
506
+ class="py-8 text-center text-sm text-muted"
507
+ >
508
+ No knowledge data available.
509
+ </div>
510
+ </div>
511
+
512
+ <!-- LINKED AGENTS -->
513
+ <div v-else-if="item.value === 'agents'" class="space-y-4 mt-6">
514
+ <UCard v-if="linkedAgentCount > 0">
515
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
516
+ Linked to {{ linkedAgentCount }} agent{{ linkedAgentCount === 1 ? '' : 's' }}
517
+ </p>
518
+ <div class="space-y-2">
519
+ <NuxtLink
520
+ v-for="aid in linkedAgentIds"
521
+ :key="aid"
522
+ :to="`/agents/${aid}`"
523
+ class="flex items-center justify-between p-3 rounded-lg border border-default hover:border-primary/40 transition-colors"
524
+ >
525
+ <span class="font-mono text-sm">{{ aid }}</span>
526
+ <UIcon name="i-lucide-arrow-right" class="size-4 text-muted" />
527
+ </NuxtLink>
528
+ </div>
529
+ </UCard>
530
+ <div v-else class="py-8 text-center text-sm text-muted">
531
+ Not linked to any agent yet. Open an agent's edit drawer
532
+ to attach this persona.
533
+ </div>
534
+ </div>
535
+ </template>
536
+ </UTabs>
537
+
538
+ <!-- EDIT DRAWER -->
539
+ <USlideover
540
+ :open="editOpen"
541
+ :ui="{ content: 'max-w-2xl w-full' }"
542
+ @update:open="(v) => v ? null : tryCloseEdit()"
543
+ >
544
+ <template #content>
545
+ <UCard
546
+ v-if="draft"
547
+ :ui="{
548
+ root: 'h-full flex flex-col rounded-none',
549
+ body: 'flex-1 overflow-y-auto',
550
+ }"
551
+ >
552
+ <template #header>
553
+ <div class="flex items-center justify-between">
554
+ <h2 class="text-xl font-bold">Edit {{ draft.name || 'persona' }}</h2>
555
+ <UButton
556
+ icon="i-lucide-x"
557
+ variant="ghost"
558
+ size="sm"
559
+ aria-label="Close"
560
+ @click="tryCloseEdit"
561
+ />
562
+ </div>
563
+ </template>
564
+
565
+ <div class="space-y-5">
566
+ <section class="space-y-3">
567
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
568
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
569
+ <UFormField label="Name" required>
570
+ <UInput v-model="draft.name" class="w-full" @update:model-value="markDirty" />
571
+ </UFormField>
572
+ <UFormField label="Title">
573
+ <UInput v-model="draft.title" class="w-full" @update:model-value="markDirty" />
574
+ </UFormField>
575
+ <UFormField label="Source">
576
+ <UInput v-model="draft.source" class="w-full" @update:model-value="markDirty" />
577
+ </UFormField>
578
+ <UFormField label="Tagline">
579
+ <UInput v-model="draft.tagline" class="w-full" @update:model-value="markDirty" />
580
+ </UFormField>
581
+ </div>
582
+ </section>
583
+
584
+ <section class="space-y-3">
585
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Behavioural DNA</h3>
586
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-3">
587
+ <UFormField label="MBTI">
588
+ <USelect v-model="draft.mbti" :items="mbtiOptions" class="w-full" @update:model-value="markDirty" />
589
+ </UFormField>
590
+ <UFormField label="DISC primary">
591
+ <USelect v-model="draft.disc.primary" :items="discOptions" class="w-full" @update:model-value="markDirty" />
592
+ </UFormField>
593
+ <UFormField label="Enneagram">
594
+ <UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" @update:model-value="markDirty" />
595
+ </UFormField>
596
+ <UFormField label="Wing">
597
+ <UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" @update:model-value="markDirty" />
598
+ </UFormField>
599
+ </div>
600
+ <div class="space-y-2">
601
+ <div
602
+ v-for="key in bigFiveKeys"
603
+ :key="key"
604
+ class="flex items-center gap-3"
605
+ >
606
+ <label class="text-xs text-muted w-36 shrink-0 capitalize">{{ key }}</label>
607
+ <UInput
608
+ v-model.number="(draft.big_five as any)[key]"
609
+ type="number"
610
+ :min="0"
611
+ :max="100"
612
+ class="w-20"
613
+ @update:model-value="markDirty"
614
+ />
615
+ <input
616
+ v-model.number="(draft.big_five as any)[key]"
617
+ type="range"
618
+ :min="0"
619
+ :max="100"
620
+ class="flex-1"
621
+ @input="markDirty"
622
+ />
623
+ </div>
624
+ </div>
625
+ </section>
626
+
627
+ <section class="space-y-3">
628
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Knowledge</h3>
629
+ <UFormField label="Mental models" help="comma-separated">
630
+ <UInput
631
+ :model-value="listToCsv(draft.mental_models)"
632
+ @update:model-value="(v: string) => { if (draft) { draft.mental_models = csvToList(v); markDirty() } }"
633
+ class="w-full"
634
+ />
635
+ </UFormField>
636
+ <UFormField label="Expertise domains" help="comma-separated">
637
+ <UInput
638
+ :model-value="listToCsv(draft.expertise_domains)"
639
+ @update:model-value="(v: string) => { if (draft) { draft.expertise_domains = csvToList(v); markDirty() } }"
640
+ class="w-full"
641
+ />
642
+ </UFormField>
643
+ <UFormField label="Frameworks" help="comma-separated">
644
+ <UInput
645
+ :model-value="listToCsv(draft.frameworks)"
646
+ @update:model-value="(v: string) => { if (draft) { draft.frameworks = csvToList(v); markDirty() } }"
647
+ class="w-full"
648
+ />
649
+ </UFormField>
650
+ </section>
651
+
652
+ <section class="space-y-3">
653
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
654
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
655
+ <UFormField label="Tone">
656
+ <UInput v-model="draft.communication.tone" class="w-full" @update:model-value="markDirty" />
657
+ </UFormField>
658
+ <UFormField label="Vocabulary level">
659
+ <USelect v-model="draft.communication.vocabulary_level" :items="vocabOptions" class="w-full" @update:model-value="markDirty" />
660
+ </UFormField>
661
+ </div>
662
+ </section>
663
+ </div>
664
+
665
+ <template #footer>
666
+ <div class="flex items-center justify-between gap-2">
667
+ <span v-if="dirty" class="text-xs text-yellow-400">
668
+ <UIcon name="i-lucide-circle-dot" class="size-3 inline" />
669
+ Unsaved changes
670
+ </span>
671
+ <span v-else class="text-xs text-muted">No changes</span>
672
+ <div class="flex gap-2">
673
+ <UButton label="Cancel" variant="ghost" :disabled="saving" @click="tryCloseEdit" />
674
+ <UButton
675
+ label="Save"
676
+ icon="i-lucide-check"
677
+ :loading="saving"
678
+ :disabled="!dirty"
679
+ @click="saveEdit"
680
+ />
681
+ </div>
682
+ </div>
683
+ </template>
684
+ </UCard>
685
+ </template>
686
+ </USlideover>
687
+ </div>
688
+ </DashboardState>
689
+ </template>
690
+ </UDashboardPanel>
691
+ </template>