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:
anadoris007 2026-04-20 22:04:28 +05:30
parent d4557fd8b8
commit 0c9b0c025d
5 changed files with 412 additions and 4 deletions

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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
}
]

View File

@ -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>