feat(narrative): add StoryTimelineView with StoryBeat and CharacterCard
New frontend surface for reading generated stories: - StoryBeat.vue: renders a round's prose with character attribution - CharacterCard.vue: compact roster card with animated emotion bars - StoryTimelineView.vue: reading view with Init Characters, Refresh, Translate Next Round, and a tone input - Route: /story/:simulationId Recently-active characters are highlighted in the roster. Styling uses the project's cream/brass palette for consistency with existing views. Frontend builds cleanly (vite build). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d4557fd8b8
commit
0c9b0c025d
|
|
@ -1435,7 +1435,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -1913,7 +1912,6 @@
|
|||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -2053,7 +2051,6 @@
|
|||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -2128,7 +2125,6 @@
|
|||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.25",
|
||||
"@vue/compiler-sfc": "3.5.25",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<div class="character-card" :class="{ 'has-action': hasRecentAction }">
|
||||
<div class="name">{{ character.name }}</div>
|
||||
<div class="emotions">
|
||||
<span v-for="(val, emo) in topEmotions" :key="emo" class="emotion">
|
||||
<span class="emo-label">{{ emo }}</span>
|
||||
<span class="emo-bar">
|
||||
<span class="emo-bar-fill" :style="{ width: (val * 100) + '%' }"></span>
|
||||
</span>
|
||||
<span class="emo-val">{{ val.toFixed(2) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
character: { type: Object, required: true },
|
||||
hasRecentAction: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
// Surface the top 3 emotions by magnitude; hide anything at exactly 0
|
||||
const topEmotions = computed(() => {
|
||||
const current = props.character.emotional_state?.current || {}
|
||||
const sorted = Object.entries(current)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
return Object.fromEntries(sorted)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-card {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.3rem;
|
||||
background: #2a2416;
|
||||
color: #faf7f0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
min-width: 180px;
|
||||
vertical-align: top;
|
||||
border: 1px solid transparent;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.character-card.has-action {
|
||||
border-color: #c9a45b;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.emotions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-family: 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.emotion {
|
||||
display: grid;
|
||||
grid-template-columns: 55px 1fr 35px;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.emo-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #c9a45b;
|
||||
}
|
||||
.emo-bar {
|
||||
height: 4px;
|
||||
background: #3d3520;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.emo-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #c9a45b;
|
||||
}
|
||||
.emo-val {
|
||||
text-align: right;
|
||||
color: #e5ddc4;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<article class="story-beat">
|
||||
<header class="beat-header">
|
||||
<span class="round-badge">Round {{ beat.round }}</span>
|
||||
<span v-if="beat.characters && beat.characters.length" class="characters">
|
||||
{{ beat.characters.join(' · ') }}
|
||||
</span>
|
||||
<span v-if="beat.platform" class="platform-tag">{{ beat.platform }}</span>
|
||||
</header>
|
||||
<div class="prose">
|
||||
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
beat: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const paragraphs = computed(() =>
|
||||
(props.beat.prose || '').split(/\n\n+/).filter(p => p.trim())
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.story-beat {
|
||||
margin: 0 0 2.5rem;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-left: 3px solid #c9a45b;
|
||||
background: #faf7f0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.beat-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #7d6b3f;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.round-badge {
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.characters {
|
||||
font-style: italic;
|
||||
}
|
||||
.platform-tag {
|
||||
margin-left: auto;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: #eadfb8;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.prose p {
|
||||
line-height: 1.75;
|
||||
margin: 0 0 1rem;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
color: #2a2416;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.prose p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,6 +5,7 @@ import SimulationView from '../views/SimulationView.vue'
|
|||
import SimulationRunView from '../views/SimulationRunView.vue'
|
||||
import ReportView from '../views/ReportView.vue'
|
||||
import InteractionView from '../views/InteractionView.vue'
|
||||
import StoryTimelineView from '../views/StoryTimelineView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
|
|
@ -41,6 +42,12 @@ const routes = [
|
|||
name: 'Interaction',
|
||||
component: InteractionView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/story/:simulationId',
|
||||
name: 'Story',
|
||||
component: StoryTimelineView,
|
||||
props: true
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
<template>
|
||||
<div class="story-timeline">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>Story Timeline</h1>
|
||||
<p class="sim-id">Simulation: <code>{{ simId }}</code></p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label class="tone-field">
|
||||
Tone
|
||||
<input v-model="tone" placeholder="e.g. dark political thriller" />
|
||||
</label>
|
||||
<button @click="initChars" :disabled="busy" class="secondary">
|
||||
Init Characters
|
||||
</button>
|
||||
<button @click="refresh" :disabled="busy" class="secondary">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</button>
|
||||
<button @click="translateNext" :disabled="busy" class="primary">
|
||||
{{ translating ? 'Generating…' : 'Translate Next Round' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
|
||||
<section v-if="characters.length" class="character-roster">
|
||||
<h2>Characters</h2>
|
||||
<div class="cards">
|
||||
<CharacterCard
|
||||
v-for="c in characters"
|
||||
:key="c.id"
|
||||
:character="c"
|
||||
:has-recent-action="recentCharacters.has(c.name)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="beats.length === 0 && !loading" class="empty">
|
||||
No story yet. Initialize characters, then translate rounds to generate the narrative.
|
||||
</div>
|
||||
|
||||
<StoryBeat v-for="beat in beats" :key="beat.round" :beat="beat" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
getFullStory,
|
||||
translateRound,
|
||||
getCharacters,
|
||||
initCharacters,
|
||||
} from '../api/narrative'
|
||||
import StoryBeat from '../components/StoryBeat.vue'
|
||||
import CharacterCard from '../components/CharacterCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const simId = route.params.simulationId
|
||||
|
||||
const beats = ref([])
|
||||
const characters = ref([])
|
||||
const loading = ref(false)
|
||||
const translating = ref(false)
|
||||
const initting = ref(false)
|
||||
const error = ref('')
|
||||
const tone = ref('dark political thriller')
|
||||
|
||||
const busy = computed(() => loading.value || translating.value || initting.value)
|
||||
|
||||
// Characters that acted in the most recent beat — highlighted in the roster
|
||||
const recentCharacters = computed(() => {
|
||||
const last = beats.value[beats.value.length - 1]
|
||||
return new Set(last?.characters || [])
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await getFullStory(simId)
|
||||
beats.value = res.beats || []
|
||||
await loadCharacters()
|
||||
} catch (e) {
|
||||
error.value = e?.response?.data?.error || e.message || 'Failed to load story'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCharacters() {
|
||||
try {
|
||||
const res = await getCharacters(simId)
|
||||
characters.value = res.characters || []
|
||||
} catch (e) {
|
||||
// non-fatal — characters may not be initialized yet
|
||||
}
|
||||
}
|
||||
|
||||
async function initChars() {
|
||||
initting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await initCharacters(simId)
|
||||
await loadCharacters()
|
||||
} catch (e) {
|
||||
error.value = e?.response?.data?.error || e.message || 'Failed to initialize characters'
|
||||
} finally {
|
||||
initting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function translateNext() {
|
||||
translating.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const nextRound = beats.value.length + 1
|
||||
await translateRound({ sim_id: simId, round: nextRound, tone: tone.value })
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
error.value = e?.response?.data?.error || e.message || 'Translation failed'
|
||||
} finally {
|
||||
translating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.story-timeline {
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 6rem;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.25rem;
|
||||
border-bottom: 1px solid #e5ddc4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-left h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-family: Georgia, serif;
|
||||
color: #2a2416;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.sim-id {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #7d6b3f;
|
||||
}
|
||||
.sim-id code {
|
||||
background: #eadfb8;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tone-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: #7d6b3f;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.tone-field input {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid #d4c893;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
min-width: 220px;
|
||||
background: #faf7f0;
|
||||
}
|
||||
button {
|
||||
padding: 0.55rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
button.primary {
|
||||
background: #c9a45b;
|
||||
color: white;
|
||||
}
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: #7d6b3f;
|
||||
border: 1px solid #d4c893;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
.error {
|
||||
background: #ffe5e5;
|
||||
color: #8b0000;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.character-roster {
|
||||
margin-bottom: 2.5rem;
|
||||
padding: 1.25rem;
|
||||
background: #f5efd9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.character-roster h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #7d6b3f;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 3rem 1rem;
|
||||
font-style: italic;
|
||||
border: 1px dashed #d4c893;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue