Add AI-assisted world and character drafting flows
All checks were successful
CI / validate (push) Successful in 14m3s
All checks were successful
CI / validate (push) Successful in 14m3s
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { CharacterDraft } from '@dng/shared'
|
||||
|
||||
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
|
||||
type Controller = 'human' | 'ai' | 'delegated'
|
||||
|
||||
@@ -22,6 +24,7 @@ const payload = ref<CampaignPayload | null>(null)
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const mutating = ref(false)
|
||||
const suggestingCharacter = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const mutationError = ref('')
|
||||
const actionText = ref('')
|
||||
@@ -33,13 +36,16 @@ const createKind = ref<'human' | 'ai'>('human')
|
||||
const characterForm = reactive({
|
||||
name: '', concept: '', hp: 12, maxHp: 12, defense: 12, proficiency: 2,
|
||||
abilities: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 } as Record<AbilityKey, number>,
|
||||
persona: { voice: '', motivation: '', flaw: '', bond: '' },
|
||||
})
|
||||
const inventoryText = ref('')
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const campaign = computed(() => payload.value?.campaign)
|
||||
const currentRound = computed(() => payload.value?.round)
|
||||
const isOwner = computed(() => Boolean(campaign.value && auth.session.value?.user.id === campaign.value.owner_id))
|
||||
const currentMember = computed(() => payload.value?.members.find(member => member.user_id === auth.session.value?.user.id))
|
||||
const ownedCharacter = computed(() => payload.value?.characters.find(character => character.user_id === auth.session.value?.user.id))
|
||||
const myCharacter = computed(() => payload.value?.characters.find(character => character.user_id === auth.session.value?.user.id && character.controller === 'human'))
|
||||
const myIntent = computed(() => payload.value?.intents.find(intent =>
|
||||
intent.member_id === currentMember.value?.id || intent.character_id === myCharacter.value?.id,
|
||||
@@ -178,6 +184,37 @@ async function toggleTakeover(member: Record<string, any>) {
|
||||
}))
|
||||
}
|
||||
|
||||
function toggleCharacterForm() {
|
||||
showCharacterForm.value = !showCharacterForm.value
|
||||
if (showCharacterForm.value) createKind.value = ownedCharacter.value ? 'ai' : 'human'
|
||||
}
|
||||
|
||||
async function suggestCharacter() {
|
||||
if (suggestingCharacter.value) return
|
||||
suggestingCharacter.value = true
|
||||
mutationError.value = ''
|
||||
try {
|
||||
const result = await api<{ character: CharacterDraft }>(`/api/v1/campaigns/${campaignId.value}/characters/suggest`, {
|
||||
method: 'POST',
|
||||
body: { concept: characterForm.concept.trim(), controller: createKind.value },
|
||||
})
|
||||
const suggestion = result.character
|
||||
characterForm.name = suggestion.name
|
||||
characterForm.concept = suggestion.concept
|
||||
characterForm.hp = suggestion.hp
|
||||
characterForm.maxHp = suggestion.maxHp
|
||||
characterForm.defense = suggestion.defense
|
||||
characterForm.proficiency = suggestion.proficiency
|
||||
characterForm.abilities = { ...suggestion.abilities }
|
||||
characterForm.persona = { ...suggestion.persona }
|
||||
inventoryText.value = suggestion.inventory.join('\n')
|
||||
} catch (error) {
|
||||
mutationError.value = messageFrom(error)
|
||||
} finally {
|
||||
suggestingCharacter.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createCharacter() {
|
||||
const controller: Controller = createKind.value === 'ai' ? 'ai' : 'human'
|
||||
const success = await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/characters`, {
|
||||
@@ -185,13 +222,17 @@ async function createCharacter() {
|
||||
body: {
|
||||
name: characterForm.name.trim(), concept: characterForm.concept.trim(), controller,
|
||||
abilities: characterForm.abilities, hp: characterForm.hp, maxHp: characterForm.maxHp,
|
||||
defense: characterForm.defense, proficiency: characterForm.proficiency, inventory: [], statuses: [], persona: {},
|
||||
defense: characterForm.defense, proficiency: characterForm.proficiency,
|
||||
inventory: inventoryText.value.split(/\n|,/).map(item => item.trim()).filter(Boolean).slice(0, 50),
|
||||
statuses: [], persona: characterForm.persona,
|
||||
},
|
||||
}))
|
||||
if (success) {
|
||||
showCharacterForm.value = false
|
||||
characterForm.name = ''
|
||||
characterForm.concept = ''
|
||||
inventoryText.value = ''
|
||||
characterForm.persona = { voice: '', motivation: '', flaw: '', bond: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,13 +263,16 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-else class="campaign-layout">
|
||||
<aside class="party-panel">
|
||||
<header class="panel-title"><span>PARTY / {{ payload.characters.length }}</span><button v-if="!myCharacter || isOwner" aria-label="Create character" @click="showCharacterForm = !showCharacterForm">{{ showCharacterForm ? '×' : '+' }}</button></header>
|
||||
<header class="panel-title"><span>PARTY / {{ payload.characters.length }}</span><button v-if="!ownedCharacter || isOwner" aria-label="Create character" @click="toggleCharacterForm">{{ showCharacterForm ? '×' : '+' }}</button></header>
|
||||
<form v-if="showCharacterForm" class="character-create" @submit.prevent="createCharacter">
|
||||
<div v-if="isOwner" class="kind-switch"><button type="button" :class="{active:createKind==='human'}" @click="createKind='human'">HUMAN</button><button type="button" :class="{active:createKind==='ai'}" @click="createKind='ai'">AI HERO</button></div>
|
||||
<div v-if="isOwner" class="kind-switch"><button type="button" :class="{active:createKind==='human'}" :disabled="Boolean(ownedCharacter)" @click="createKind='human'">HUMAN</button><button type="button" :class="{active:createKind==='ai'}" @click="createKind='ai'">AI HERO</button></div>
|
||||
<input v-model="characterForm.name" required maxlength="80" placeholder="Character name">
|
||||
<textarea v-model="characterForm.concept" required maxlength="600" placeholder="Concept, role, personality" />
|
||||
<button type="button" class="ai-draft" :disabled="suggestingCharacter || mutating" @click="suggestCharacter">{{ suggestingCharacter ? 'COAUTHOR IS DRAFTING…' : '✦ DRAFT WITH AI' }}</button>
|
||||
<div class="ability-editor"><label v-for="key in (['str','dex','con','int','wis','cha'] as AbilityKey[])" :key="key"><span>{{ key }}</span><input v-model.number="characterForm.abilities[key]" type="number" min="1" max="30"></label></div>
|
||||
<div class="stat-editor"><label>HP <input v-model.number="characterForm.hp" type="number" min="1" :max="characterForm.maxHp"></label><label>MAX <input v-model.number="characterForm.maxHp" type="number" min="1"></label><label>DEF <input v-model.number="characterForm.defense" type="number" min="1" max="40"></label></div>
|
||||
<textarea v-model="inventoryText" maxlength="800" placeholder="Starting items, one per line" />
|
||||
<details class="persona-editor"><summary>VOICE & PERSONALITY</summary><input v-model="characterForm.persona.voice" maxlength="240" placeholder="Voice"><input v-model="characterForm.persona.motivation" maxlength="300" placeholder="Motivation"><input v-model="characterForm.persona.flaw" maxlength="300" placeholder="Flaw"><input v-model="characterForm.persona.bond" maxlength="300" placeholder="Bond"></details>
|
||||
<button class="acid-button" :disabled="mutating">{{ mutating ? 'CREATING…' : 'ADD TO PARTY' }}</button>
|
||||
</form>
|
||||
<div v-if="!payload.characters.length" class="empty-card"><b>NO HEROES YET</b><p>Create a character to begin this campaign.</p></div>
|
||||
@@ -289,4 +333,5 @@ onBeforeUnmount(() => {
|
||||
.invite-box button{width:100%}
|
||||
.takeover-toggle{margin-top:7px;padding:4px 6px;border:1px solid var(--line);background:transparent;color:var(--muted);font:500 6px var(--mono);text-align:left}
|
||||
.takeover-toggle.enabled{border-color:var(--acid-dim);color:var(--acid)}
|
||||
.ai-draft{min-height:34px;border:1px solid var(--acid-dim);background:rgba(207,255,70,.04);color:var(--acid);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor{border:1px solid var(--line);padding:9px}.persona-editor summary{cursor:pointer;color:var(--muted);font:600 7px var(--mono);letter-spacing:.1em}.persona-editor input{margin-top:7px}
|
||||
</style>
|
||||
|
||||
@@ -6,21 +6,50 @@ interface CampaignRow {
|
||||
status: string
|
||||
updated_at: string
|
||||
}
|
||||
interface DraftSession {
|
||||
id: string
|
||||
status: string
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
generated_world?: { title?: string; premise?: string } | null
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const auth = useDngAuth()
|
||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
const campaigns = ref<CampaignRow[]>([])
|
||||
const drafts = ref<DraftSession[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||
: campaigns.value)
|
||||
: filter.value === 'drafts' ? [] : campaigns.value)
|
||||
const visibleDrafts = computed(() => filter.value === 'active' ? [] : drafts.value)
|
||||
const displayName = computed(() => {
|
||||
const value = auth.session.value?.user.user_metadata?.display_name
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : 'ADVENTURER'
|
||||
})
|
||||
|
||||
function draftTitle(draft: DraftSession) {
|
||||
return draft.generated_world?.title || 'UNFINISHED UNIVERSE'
|
||||
}
|
||||
|
||||
function draftSummary(draft: DraftSession) {
|
||||
return draft.generated_world?.premise
|
||||
|| draft.messages.find(message => message.role === 'user')?.content
|
||||
|| 'Continue your conversation with the coauthor.'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns')
|
||||
campaigns.value = result.campaigns
|
||||
await auth.restore()
|
||||
const [campaignResult, draftResult] = await Promise.all([
|
||||
api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns'),
|
||||
api<{ sessions: DraftSession[] }>('/api/v1/coauthor/sessions'),
|
||||
])
|
||||
campaigns.value = campaignResult.campaigns
|
||||
drafts.value = draftResult.sessions
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||
@@ -34,13 +63,13 @@ onMounted(async () => {
|
||||
<AppShell section="COMMAND DECK">
|
||||
<div class="dash-wrap noise">
|
||||
<section class="dash-head">
|
||||
<div><p class="kicker">WELCOME BACK, MARA</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
|
||||
<div><p class="kicker">WELCOME BACK, {{ displayName.toUpperCase() }}</p><h1>YOUR UNIVERSES<span>.</span></h1><p>Every world remembers where you left it.</p></div>
|
||||
<NuxtLink to="/worlds/new" class="create-button"><span>+</span> CREATE A UNIVERSE</NuxtLink>
|
||||
</section>
|
||||
|
||||
<div class="filter-row">
|
||||
<button v-for="item in ['all','active','drafts']" :key="item" :class="{active:filter===item}" @click="filter=item as typeof filter">{{ item }}</button>
|
||||
<span>{{ campaigns.length }} / 5 ALPHA CAMPAIGNS</span>
|
||||
<span>{{ campaigns.length }} CAMPAIGNS · {{ drafts.length }} DRAFTS</span>
|
||||
</div>
|
||||
|
||||
<section class="world-grid">
|
||||
@@ -50,16 +79,20 @@ onMounted(async () => {
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>PRIVATE MULTIPLAYER</small><h2>{{ campaign.title }}</h2><p>{{ campaign.current_scene }}</p><div><b>CONTINUE STORY</b><span>{{ new Date(campaign.updated_at).toLocaleDateString() }}</span></div></div>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-for="draft in visibleDrafts" :key="draft.id" :to="`/worlds/new?session=${draft.id}`" class="world-card active-world draft-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ draft.status.toUpperCase() }} DRAFT</span></div>
|
||||
<div class="world-info"><small>PRIVATE COAUTHOR SESSION</small><h2>{{ draftTitle(draft) }}</h2><p>{{ draftSummary(draft) }}</p><div><b>RESUME CREATION</b><span>{{ new Date(draft.updated_at).toLocaleDateString() }}</span></div></div>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/worlds/new" class="world-card new-card">
|
||||
<span class="plus">+</span><h2>MAKE THE NEXT<br>IMPOSSIBLE PLACE</h2><p>Begin with a sentence. The coauthor will ask the rest.</p>
|
||||
</NuxtLink>
|
||||
</section>
|
||||
|
||||
<section class="system-strip"><span><i /> OPENROUTER ADAPTER READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
|
||||
<section class="system-strip"><span><i /> AI COAUTHOR READY</span><span>SERVER DICE <b>ONLINE</b></span><span>PRIVATE BY DEFAULT</span></section>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dash-wrap{min-height:calc(100vh - 76px);padding:clamp(42px,6vw,86px) clamp(20px,6vw,88px)}.dash-head{display:flex;align-items:end;justify-content:space-between;gap:30px}.kicker{font:500 9px var(--mono);letter-spacing:.2em;color:var(--acid)}.dash-head h1{margin:14px 0 10px;font:600 clamp(44px,6vw,82px)/1 var(--display);letter-spacing:-.06em}.dash-head h1 span{color:var(--acid)}.dash-head p{color:var(--muted)}.create-button{display:flex;align-items:center;gap:18px;padding:18px 22px;background:var(--acid);color:#090909;text-decoration:none;font:600 10px var(--mono);letter-spacing:.12em}.create-button span{font-size:20px}.filter-row{display:flex;align-items:center;gap:10px;margin:58px 0 24px;border-bottom:1px solid var(--line)}.filter-row button{padding:0 4px 16px;background:none;border:0;color:var(--muted);font:500 9px var(--mono);text-transform:uppercase;letter-spacing:.14em;margin-right:18px}.filter-row button.active{color:var(--ink);border-bottom:2px solid var(--acid)}.filter-row>span{margin-left:auto;padding-bottom:16px;font:500 8px var(--mono);color:var(--muted)}.world-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.load-state{grid-column:1/-1;padding:34px;border:1px solid var(--line);font:500 9px var(--mono);color:var(--muted)}.load-state.error{color:#ff9d85}.load-state a{color:var(--acid)}.world-card{min-height:420px;border:1px solid var(--line);color:var(--ink);text-decoration:none;background:#0e0e0d;transition:.25s ease}.world-card:hover{border-color:#65655e;transform:translateY(-3px)}.active-world{display:grid;grid-template-columns:.75fr 1.25fr}.world-art{position:relative;overflow:hidden;display:grid;place-items:center;background:radial-gradient(circle at 50% 60%,#7d8240 0 2%,#303018 4%,#0b0b0a 36%,#030303 72%)}.world-art:before{content:"";position:absolute;width:320px;height:320px;border:1px solid #38382d;border-radius:50%;box-shadow:0 0 0 34px #111,0 0 0 35px #26261d}.eclipse{position:absolute;width:126px;height:126px;border-radius:50%;background:#020202;box-shadow:0 0 50px var(--acid-dim)}.world-art span{position:absolute;left:20px;top:20px;padding:9px 11px;background:var(--acid);color:#0a0a0a;font:600 8px var(--mono);letter-spacing:.12em}.world-info{padding:42px;display:flex;flex-direction:column}.world-info small{font:500 8px var(--mono);letter-spacing:.14em;color:var(--acid);text-transform:uppercase}.world-info h2,.new-card h2{font:600 clamp(25px,3vw,42px)/1.05 var(--display);letter-spacing:-.05em;margin:22px 0}.world-info p,.new-card p{color:var(--muted);font-size:13px;line-height:1.7;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden}.world-info div{margin-top:auto;padding-top:26px;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);color:var(--muted)}.world-info b{color:var(--acid)}.new-card{padding:48px;display:flex;flex-direction:column;justify-content:flex-end;background:linear-gradient(145deg,#121211,#090909)}.new-card .plus{margin-bottom:auto;font:300 42px var(--body);color:var(--acid)}.system-strip{margin-top:32px;padding:18px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.12em;color:var(--muted)}.system-strip i{display:inline-block;width:6px;height:6px;background:var(--acid);border-radius:50%;margin-right:8px}.system-strip b{color:var(--acid)}@media(max-width:1050px){.world-grid{grid-template-columns:1fr}}@media(max-width:850px){.dash-head{align-items:start;flex-direction:column}.active-world{grid-template-columns:1fr}.world-art{min-height:280px}.system-strip{gap:16px;flex-wrap:wrap}}@media(max-width:520px){.filter-row>span{display:none}.world-info,.new-card{padding:28px}.active-world{min-height:580px}}
|
||||
.draft-world .world-art{background:radial-gradient(circle at 50% 60%,#3f4720 0 2%,#1c2011 9%,#080808 58%)}.draft-world .world-art span{background:transparent;color:var(--acid);border:1px solid var(--acid-dim)}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorldStarter } from '@dng/shared'
|
||||
import { WorldStarterSchema, type WorldStarter } from '@dng/shared'
|
||||
|
||||
type Stage = 'seed' | 'questions' | 'generating' | 'preview' | 'confirming'
|
||||
interface Message { id: string; role: 'coauthor' | 'player' | 'status'; body: string; label?: string }
|
||||
interface DynamicQuestion { id: string; label: string; options: string[] }
|
||||
interface RespondResult { readyToGenerate: boolean; question?: DynamicQuestion }
|
||||
interface StoredMessage { role: 'user' | 'assistant'; content: string }
|
||||
interface StoredSession {
|
||||
id: string
|
||||
status: 'collecting' | 'generating' | 'ready' | 'failed' | 'confirmed'
|
||||
messages: StoredMessage[]
|
||||
current_question?: DynamicQuestion | null
|
||||
generatedWorld?: unknown
|
||||
generated_world?: unknown
|
||||
confirmed_world_id?: string | null
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const route = useRoute()
|
||||
const stage = ref<Stage>('seed')
|
||||
const busy = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
@@ -47,6 +58,63 @@ async function begin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resume(id: string) {
|
||||
busy.value = true
|
||||
retryAction.value = 'respond'
|
||||
try {
|
||||
const result = await api<{ session: StoredSession }>(`/api/v1/coauthor/sessions/${id}`)
|
||||
const session = result.session
|
||||
sessionId.value = session.id
|
||||
const storedMessages = Array.isArray(session.messages) ? session.messages : []
|
||||
seed.value = storedMessages.find(message => message.role === 'user')?.content ?? seed.value
|
||||
answerCount.value = Math.max(0, storedMessages.filter(message => message.role === 'user').length - 1)
|
||||
let questionNumber = 0
|
||||
messages.value = [messages.value[0]!, ...storedMessages.map((message, index) => {
|
||||
if (message.role === 'assistant') questionNumber += 1
|
||||
return {
|
||||
id: `stored-${index}`,
|
||||
role: message.role === 'assistant' ? 'coauthor' as const : 'player' as const,
|
||||
body: message.content,
|
||||
label: message.role === 'assistant' ? `COAUTHOR · QUESTION ${questionNumber} OF 4` : undefined,
|
||||
}
|
||||
})]
|
||||
|
||||
if (session.status === 'confirmed') {
|
||||
await navigateTo('/dashboard')
|
||||
return
|
||||
}
|
||||
if (session.status === 'ready') {
|
||||
const parsed = WorldStarterSchema.safeParse(session.generatedWorld ?? session.generated_world)
|
||||
if (!parsed.success) throw new Error('This saved world draft is invalid. Generate it again.')
|
||||
draft.value = parsed.data
|
||||
stage.value = 'preview'
|
||||
addMessage('status', `“${parsed.data.title}” was restored.`, 'DRAFT RESTORED')
|
||||
return
|
||||
}
|
||||
if (session.status === 'generating') {
|
||||
busy.value = false
|
||||
await generate()
|
||||
return
|
||||
}
|
||||
if (session.status === 'failed') {
|
||||
stage.value = 'questions'
|
||||
retryAction.value = 'generate'
|
||||
addMessage('status', 'The previous generation failed. Your answers are safe.', 'ERROR')
|
||||
return
|
||||
}
|
||||
|
||||
stage.value = 'questions'
|
||||
currentQuestion.value = session.current_question ?? null
|
||||
if (!currentQuestion.value) await requestNextQuestion()
|
||||
} catch (error) {
|
||||
stage.value = 'seed'
|
||||
sessionId.value = null
|
||||
addMessage('status', errorText(error) || 'This saved coauthor session could not be restored.', 'ERROR')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function answerQuestion(key: string, value: string) {
|
||||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||||
busy.value = true
|
||||
@@ -154,6 +222,11 @@ async function confirm() {
|
||||
stage.value = 'preview'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const resumeId = typeof route.query.session === 'string' ? route.query.session : ''
|
||||
if (resumeId) void resume(resumeId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -164,7 +237,7 @@ async function confirm() {
|
||||
<div class="progress"><i :style="{ width: `${progress}%` }" /></div>
|
||||
<ol>
|
||||
<li :class="{ active: stage === 'seed', done: stage !== 'seed' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
|
||||
<li :class="{ active: stage === 'questions', done: answerCount >= 3 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / up to 5 answers captured.</small></span></li>
|
||||
<li :class="{ active: stage === 'questions', done: answerCount >= 4 }"><b>02</b><span>Shape the signal<small>{{ answerCount }} / 4 answers captured.</small></span></li>
|
||||
<li :class="{ active: stage === 'generating', done: stage === 'preview' || stage === 'confirming' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
|
||||
<li :class="{ active: stage === 'preview' || stage === 'confirming' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
|
||||
</ol>
|
||||
|
||||
Reference in New Issue
Block a user