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",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
|
@ -1913,7 +1912,6 @@
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -2053,7 +2051,6 @@
|
||||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -2128,7 +2125,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
|
||||||
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.25",
|
"@vue/compiler-dom": "3.5.25",
|
||||||
"@vue/compiler-sfc": "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 SimulationRunView from '../views/SimulationRunView.vue'
|
||||||
import ReportView from '../views/ReportView.vue'
|
import ReportView from '../views/ReportView.vue'
|
||||||
import InteractionView from '../views/InteractionView.vue'
|
import InteractionView from '../views/InteractionView.vue'
|
||||||
|
import StoryTimelineView from '../views/StoryTimelineView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
|
|
@ -41,6 +42,12 @@ const routes = [
|
||||||
name: 'Interaction',
|
name: 'Interaction',
|
||||||
component: InteractionView,
|
component: InteractionView,
|
||||||
props: true
|
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