338 lines
29 KiB
Vue
338 lines
29 KiB
Vue
<script setup lang="ts">
|
||
import type { CharacterDraft } from '@dng/shared'
|
||
|
||
type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'
|
||
type Controller = 'human' | 'ai' | 'delegated'
|
||
|
||
interface CampaignPayload {
|
||
campaign: Record<string, any>
|
||
world: Record<string, any>
|
||
members: Array<Record<string, any> & { profile?: { display_name?: string } | null }>
|
||
characters: Array<Record<string, any>>
|
||
round: Record<string, any> | null
|
||
rounds: Array<Record<string, any>>
|
||
intents: Array<Record<string, any>>
|
||
events: Array<Record<string, any>>
|
||
diceRolls: Array<Record<string, any>>
|
||
}
|
||
|
||
const route = useRoute()
|
||
const { api } = useDngApi()
|
||
const auth = useDngAuth()
|
||
const campaignId = computed(() => String(route.params.id ?? ''))
|
||
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('')
|
||
const actionHydratedFor = ref('')
|
||
const showCharacterForm = ref(false)
|
||
const inviteLink = ref('')
|
||
const inviteCopied = ref(false)
|
||
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,
|
||
))
|
||
const isReady = computed(() => Boolean(myIntent.value?.ready))
|
||
const activeHumanMembers = computed(() => payload.value?.members.filter(member => member.active !== false) ?? [])
|
||
const readyCount = computed(() => activeHumanMembers.value.filter(member => payload.value?.intents.some(intent => intent.member_id === member.id && intent.ready)).length)
|
||
const canAct = computed(() => currentRound.value?.status === 'open' && Boolean(myCharacter.value))
|
||
const roundBusy = computed(() => ['queued', 'resolving'].includes(String(currentRound.value?.status)))
|
||
|
||
const timeline = computed(() => {
|
||
if (!payload.value) return []
|
||
const characterNames = new Map(payload.value.characters.map(character => [character.id, character.name]))
|
||
const items = [
|
||
...payload.value.rounds.filter(round => round.narration).map(round => ({
|
||
id: `narration-${round.id}`, type: 'narration', at: round.resolved_at ?? round.created_at ?? '',
|
||
label: 'GROUNDKEEPER', meta: `ROUND ${round.number} · RESOLVED`, body: round.narration,
|
||
})),
|
||
...payload.value.intents.map(intent => ({
|
||
id: `intent-${intent.id}`, type: 'intent', at: intent.updated_at ?? intent.created_at ?? '',
|
||
label: characterNames.get(intent.character_id) ?? 'PLAYER', meta: intent.ready ? 'READY' : 'DRAFT', body: intent.action,
|
||
})),
|
||
...payload.value.diceRolls.map(roll => ({
|
||
id: `roll-${roll.id}`, type: 'roll', at: roll.created_at ?? '', label: 'SERVER ROLL',
|
||
meta: roll.success === null || roll.success === undefined ? roll.check_kind : roll.success ? 'SUCCESS' : 'FAILED',
|
||
body: `${roll.formula} · [${(roll.rolls ?? []).join(', ')}] ${signed(Number(roll.modifier ?? 0))} = ${roll.total}`,
|
||
})),
|
||
...payload.value.events.map(event => ({
|
||
id: `event-${event.id}`, type: 'event', at: event.created_at ?? '', label: String(event.event_type ?? 'EVENT').toUpperCase(),
|
||
meta: 'WORLD STATE', body: event.payload?.description ?? event.description ?? JSON.stringify(event.payload ?? event),
|
||
})),
|
||
]
|
||
return items.sort((left, right) => left.at.localeCompare(right.at))
|
||
})
|
||
|
||
function signed(value: number) {
|
||
return value >= 0 ? `+ ${value}` : `− ${Math.abs(value)}`
|
||
}
|
||
|
||
function initials(name: string) {
|
||
return name.split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase()
|
||
}
|
||
|
||
function messageFrom(error: unknown) {
|
||
const candidate = error as { data?: { statusMessage?: string; message?: string }; message?: string }
|
||
return candidate.data?.statusMessage ?? candidate.data?.message ?? candidate.message ?? 'The campaign could not be updated.'
|
||
}
|
||
|
||
async function loadCampaign(initial = false) {
|
||
if (refreshing.value || !campaignId.value) return
|
||
refreshing.value = true
|
||
if (initial) loading.value = true
|
||
try {
|
||
const next = await api<CampaignPayload>(`/api/v1/campaigns/${campaignId.value}`)
|
||
payload.value = next
|
||
errorMessage.value = ''
|
||
const intentKey = `${next.round?.id ?? 'none'}:${next.intents.find((intent: Record<string, any>) => intent.member_id === next.members.find((member: Record<string, any>) => member.user_id === auth.session.value?.user.id)?.id)?.id ?? 'none'}`
|
||
if (intentKey !== actionHydratedFor.value) {
|
||
const ownMember = next.members.find((member: Record<string, any>) => member.user_id === auth.session.value?.user.id)
|
||
actionText.value = next.intents.find((intent: Record<string, any>) => intent.member_id === ownMember?.id)?.action ?? ''
|
||
actionHydratedFor.value = intentKey
|
||
}
|
||
} catch (error) {
|
||
errorMessage.value = messageFrom(error)
|
||
} finally {
|
||
refreshing.value = false
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function runMutation(task: () => Promise<unknown>) {
|
||
if (mutating.value) return false
|
||
mutating.value = true
|
||
mutationError.value = ''
|
||
try {
|
||
await task()
|
||
await loadCampaign()
|
||
return true
|
||
} catch (error) {
|
||
mutationError.value = messageFrom(error)
|
||
return false
|
||
} finally {
|
||
mutating.value = false
|
||
}
|
||
}
|
||
|
||
async function saveIntent(markReady = false) {
|
||
if (!currentRound.value || !myCharacter.value || !actionText.value.trim()) return
|
||
const roundId = String(currentRound.value.id)
|
||
const characterId = String(myCharacter.value.id)
|
||
await runMutation(async () => {
|
||
await api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/intent`, {
|
||
method: 'PUT', body: { characterId, action: actionText.value.trim(), ready: false },
|
||
})
|
||
if (markReady) await api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/ready`, { method: 'POST', body: { ready: true } })
|
||
})
|
||
}
|
||
|
||
async function editIntent() {
|
||
if (!currentRound.value) return
|
||
const roundId = String(currentRound.value.id)
|
||
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/ready`, { method: 'POST', body: { ready: false } }))
|
||
}
|
||
|
||
async function forceRound() {
|
||
if (!currentRound.value || !confirm('Continue without waiting for every player? Missing eligible characters may be delegated to AI.')) return
|
||
const roundId = String(currentRound.value.id)
|
||
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/force`, { method: 'POST', body: {} }))
|
||
}
|
||
|
||
async function retryFailedRound() {
|
||
if (!currentRound.value) return
|
||
const roundId = String(currentRound.value.id)
|
||
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/rounds/${roundId}/retry`, { method: 'POST', body: {} }))
|
||
}
|
||
|
||
async function createInvite() {
|
||
await runMutation(async () => {
|
||
const result = await api<{ token: string }>(`/api/v1/campaigns/${campaignId.value}/invites`, {
|
||
method: 'POST', body: { maxUses: 8, expiresInHours: 72 },
|
||
})
|
||
inviteLink.value = `${window.location.origin}/join/${result.token}`
|
||
})
|
||
}
|
||
|
||
async function copyInvite() {
|
||
if (!inviteLink.value) return
|
||
await navigator.clipboard.writeText(inviteLink.value)
|
||
inviteCopied.value = true
|
||
setTimeout(() => { inviteCopied.value = false }, 1_500)
|
||
}
|
||
|
||
async function toggleTakeover(member: Record<string, any>) {
|
||
await runMutation(() => api(`/api/v1/campaigns/${campaignId.value}/members/${member.id}`, {
|
||
method: 'PATCH', body: { aiTakeoverAllowed: !member.ai_takeover_allowed },
|
||
}))
|
||
}
|
||
|
||
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`, {
|
||
method: 'POST',
|
||
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: 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: '' }
|
||
}
|
||
}
|
||
|
||
function visibilitySync() {
|
||
if (!document.hidden) void loadCampaign()
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await auth.restore()
|
||
await loadCampaign(true)
|
||
pollTimer = setInterval(() => { if (!document.hidden) void loadCampaign() }, 2_000)
|
||
document.addEventListener('visibilitychange', visibilitySync)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (pollTimer) clearInterval(pollTimer)
|
||
document.removeEventListener('visibilitychange', visibilitySync)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<AppShell :section="campaign?.title?.toUpperCase() ?? 'CAMPAIGN'">
|
||
<div v-if="loading" class="state-page noise"><i class="spinner" /><p>SYNCING PRIVATE CAMPAIGN…</p></div>
|
||
<div v-else-if="!payload" class="state-page noise">
|
||
<p class="eyebrow">CAMPAIGN LINK UNAVAILABLE</p><h1>THE TRAIL GOES COLD.</h1><p>{{ errorMessage || 'This campaign does not exist or is private.' }}</p>
|
||
<div><button class="acid-button" @click="loadCampaign(true)">TRY AGAIN</button><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
|
||
</div>
|
||
|
||
<div v-else class="campaign-layout">
|
||
<aside class="party-panel">
|
||
<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'}" :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>
|
||
<article v-for="character in payload.characters" :key="character.id" class="party-member" :class="character.controller">
|
||
<div class="portrait">{{ initials(character.name) }}</div><div><h3>{{ character.name }}</h3><p>{{ character.controller === 'human' ? (character.user_id === auth.session.value?.user.id ? 'YOU' : 'PLAYER') : character.controller === 'delegated' ? 'AI STAND-IN' : 'AI COMPANION' }}</p></div><i />
|
||
</article>
|
||
<section v-if="myCharacter" class="sheet">
|
||
<small>YOUR CHARACTER</small><h2>{{ myCharacter.name }}</h2><p>{{ myCharacter.concept }}</p>
|
||
<div class="vitals"><span><b>{{ myCharacter.hp }}</b>/{{ myCharacter.max_hp }} HP</span><span><b>{{ myCharacter.defense }}</b> DEF</span></div>
|
||
<div class="abilities"><span v-for="(score,key) in myCharacter.abilities" :key="key"><small>{{ key }}</small><b>{{ score }}</b></span></div>
|
||
</section>
|
||
</aside>
|
||
|
||
<main class="chronicle noise">
|
||
<header class="scene-head"><div><p>{{ payload.world?.genre ?? 'PRIVATE WORLD' }}</p><h1>{{ campaign?.title }}</h1><small>{{ payload.world?.title ?? 'Unknown world' }}</small></div><span v-if="currentRound">ROUND {{ String(currentRound.number).padStart(2,'0') }} · {{ currentRound.status.toUpperCase() }}</span><span v-else>LOBBY</span></header>
|
||
<div v-if="errorMessage || mutationError" class="sync-error" role="alert"><span>{{ mutationError || errorMessage }}</span><button v-if="errorMessage" @click="loadCampaign()">RETRY SYNC</button></div>
|
||
<section class="timeline" aria-live="polite">
|
||
<article v-if="currentRound?.status === 'failed'" class="round-failure">
|
||
<div><small>ROUND INTERRUPTED</small><span>NO STATE WAS CHANGED</span></div>
|
||
<p>The Groundkeeper could not finish this round. The owner can retry it safely; no partial narration, rolls, or character changes were saved.</p>
|
||
</article>
|
||
<article v-if="!timeline.length" class="empty-timeline"><small>THE CHRONICLE IS QUIET</small><p>{{ currentRound ? 'Submit the first action of this round.' : 'The owner has not opened the first round yet.' }}</p></article>
|
||
<article v-for="item in timeline" :key="item.id" :class="item.type"><div><small>{{ item.label }}</small><span>{{ item.meta }}</span></div><p>{{ item.body }}</p></article>
|
||
</section>
|
||
|
||
<section class="action-dock">
|
||
<div class="action-state"><span><i :class="{ready:isReady,busy:roundBusy}" />{{ roundBusy ? 'GROUNDKEEPER IS RESOLVING' : isReady ? 'YOUR ACTION IS READY' : canAct ? 'WAITING FOR YOUR ACTION' : 'SPECTATING' }}</span><span v-if="refreshing">SYNCING…</span></div>
|
||
<template v-if="canAct">
|
||
<textarea v-model="actionText" maxlength="2000" :disabled="isReady || mutating" :placeholder="`What does ${myCharacter?.name ?? 'your hero'} do?`" @keydown.meta.enter.prevent="saveIntent(true)" />
|
||
<div class="dock-actions"><small>{{ actionText.length }} / 2000 · ⌘ ENTER TO READY</small><div><button v-if="!isReady" class="ghost-button" :disabled="mutating || !actionText.trim()" @click="saveIntent(false)">SAVE DRAFT</button><button v-if="!isReady" class="acid-button" :disabled="mutating || !actionText.trim()" @click="saveIntent(true)">MARK READY →</button><button v-else class="ghost-button" :disabled="mutating" @click="editIntent">EDIT ACTION</button></div></div>
|
||
</template>
|
||
<p v-else class="spectator-note">{{ !myCharacter ? 'Create a human character to submit actions.' : roundBusy ? 'Actions are locked while the round resolves.' : 'Waiting for the next open round.' }}</p>
|
||
</section>
|
||
</main>
|
||
|
||
<aside class="control-panel">
|
||
<header class="panel-title"><span>ROUND CONTROL</span><i :class="{live:!refreshing}" /></header>
|
||
<section class="readiness"><p><b>{{ readyCount }} / {{ activeHumanMembers.length }}</b> HUMANS READY</p><div><i :style="{width:`${activeHumanMembers.length ? readyCount/activeHumanMembers.length*100 : 0}%`} " /></div></section>
|
||
<section class="members"><small>PLAYERS</small><article v-for="member in payload.members" :key="member.id"><i :class="{ready:payload.intents.some(intent=>intent.member_id===member.id && intent.ready)}" /><span><b>{{ member.profile?.display_name || 'Adventurer' }}</b><em>{{ member.role }} · {{ payload.intents.some(intent=>intent.member_id===member.id && intent.ready) ? 'ready' : 'waiting' }}</em><button v-if="isOwner" class="takeover-toggle" :class="{enabled:member.ai_takeover_allowed}" :disabled="mutating" @click="toggleTakeover(member)">AI TAKEOVER {{ member.ai_takeover_allowed ? 'ON' : 'OFF' }}</button></span></article></section>
|
||
<section v-if="isOwner" class="invite-box"><small>INVITE PLAYERS</small><p>Private link · up to 8 joins · expires in 72 hours.</p><button v-if="!inviteLink" class="ghost-button" :disabled="mutating" @click="createInvite">CREATE INVITE LINK</button><template v-else><input :value="inviteLink" readonly aria-label="Campaign invite link"><button class="acid-button" @click="copyInvite">{{ inviteCopied ? 'COPIED' : 'COPY LINK' }}</button></template></section>
|
||
<section class="ai-order"><small>AI TURN ORDER</small><p v-if="!payload.characters.some(character=>character.controller!=='human')">No AI heroes in this party.</p><div v-for="(character,index) in payload.characters.filter(character=>character.controller!=='human')" :key="character.id"><b>{{ String(index+1).padStart(2,'0') }}</b><span>{{ character.name }}<small>{{ character.controller === 'delegated' ? 'Temporary stand-in' : 'Acts after all humans' }}</small></span></div></section>
|
||
<button v-if="isOwner && currentRound?.status==='open'" class="force-button" :disabled="mutating" @click="forceRound">CONTINUE WITHOUT WAITING <span>↗</span></button>
|
||
<button v-if="isOwner && currentRound?.status==='failed'" class="force-button" :disabled="mutating" @click="retryFailedRound">RETRY FAILED ROUND <span>↗</span></button>
|
||
<div class="mechanics-note"><i /> Dice, HP and mechanical state are resolved by the server and recorded in the campaign log.</div>
|
||
</aside>
|
||
</div>
|
||
</AppShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.campaign-layout{height:calc(100vh - 76px);display:grid;grid-template-columns:270px minmax(420px,1fr) 300px;overflow:hidden}.party-panel,.control-panel{background:#0c0c0b;overflow-y:auto}.party-panel{border-right:1px solid var(--line)}.control-panel{border-left:1px solid var(--line)}.panel-title{height:52px;padding:0 18px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);font:600 8px var(--mono);letter-spacing:.14em;color:var(--muted)}.panel-title button{border:0;background:none;color:var(--ink);font-size:20px}.panel-title>i{width:6px;height:6px;border-radius:50%;background:#5b5b55}.panel-title>i.live{background:var(--acid);box-shadow:0 0 8px var(--acid)}.party-member{display:grid;grid-template-columns:38px 1fr 8px;gap:12px;align-items:center;padding:17px 18px;border-bottom:1px solid var(--line)}.portrait{display:grid;place-items:center;width:38px;height:38px;background:#191917;border:1px solid #32322e;font:600 9px var(--mono)}.party-member h3{margin:0 0 5px;font:600 10px var(--display)}.party-member p{margin:0;font:500 7px var(--mono);color:var(--muted)}.party-member>i{width:6px;height:6px;border-radius:50%;background:#6c6c65}.party-member.human>i{background:var(--acid)}.party-member.delegated>i{background:#f3a85e}.sheet{padding:26px 18px}.sheet>small,.members>small,.ai-order>small{font:500 7px var(--mono);color:var(--acid);letter-spacing:.14em}.sheet h2{margin:14px 0 8px;font:600 19px var(--display)}.sheet>p{font-size:11px;line-height:1.55;color:var(--muted)}.vitals{display:flex;gap:8px;margin:20px 0}.vitals span{flex:1;padding:11px 8px;background:#121210;border:1px solid var(--line);font:500 7px var(--mono);color:var(--muted)}.vitals b{font-size:13px;color:var(--ink)}.abilities,.ability-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line)}.abilities span{padding:9px;background:#10100f;text-align:center}.abilities small{display:block;text-transform:uppercase;font:500 7px var(--mono);color:var(--muted)}.abilities b{font:600 12px var(--mono)}.character-create{padding:16px;border-bottom:1px solid var(--line);display:grid;gap:10px;background:#10100f}.character-create input,.character-create textarea{width:100%;border:1px solid #383833;background:#0b0b0a;color:var(--ink);padding:10px;font-size:10px}.character-create textarea{height:64px;resize:vertical}.kind-switch{display:grid;grid-template-columns:1fr 1fr}.kind-switch button{padding:8px;border:1px solid var(--line);background:none;color:var(--muted);font:600 7px var(--mono)}.kind-switch button.active{background:var(--acid);color:#090909}.ability-editor label{padding:5px;background:#111;display:grid;grid-template-columns:1fr 34px;align-items:center;font:600 7px var(--mono);text-transform:uppercase}.ability-editor input{padding:4px;text-align:center}.stat-editor{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.stat-editor label{font:600 7px var(--mono);color:var(--muted)}.empty-card{padding:28px 18px;border-bottom:1px solid var(--line)}.empty-card b{font:600 9px var(--mono)}.empty-card p{font-size:10px;color:var(--muted)}.chronicle{position:relative;overflow-y:auto;padding-bottom:200px}.scene-head{position:sticky;top:0;z-index:5;padding:28px clamp(26px,5vw,72px) 18px;display:flex;justify-content:space-between;align-items:end;background:linear-gradient(#0a0a0a 76%,transparent)}.scene-head p{margin:0;font:500 8px var(--mono);letter-spacing:.16em;color:var(--acid);text-transform:uppercase}.scene-head h1{margin:8px 0 5px;font:600 clamp(23px,3vw,38px) var(--display);letter-spacing:-.05em}.scene-head small,.scene-head>span{font:500 8px var(--mono);color:var(--muted);text-transform:uppercase}.timeline{max-width:780px;margin:12px auto;padding:0 34px}.timeline article{padding:24px 0;border-bottom:1px solid var(--line)}.timeline article>div{display:flex;justify-content:space-between}.timeline article small{font:600 8px var(--mono);letter-spacing:.12em;color:var(--acid)}.timeline article span{font:500 7px var(--mono);color:var(--muted)}.timeline article p{font:400 14px/1.75 var(--body);color:#d1d1ca}.timeline .intent{margin-left:40px;padding-left:18px;border-left:2px solid #4b4b45}.timeline .intent small{color:var(--ink)}.timeline .roll{margin:14px 0;padding:14px 17px;background:#121210;border:1px solid var(--line)}.timeline .roll p{font:500 11px var(--mono)}.timeline .event p{font-size:11px;color:var(--muted)}.empty-timeline{text-align:center;margin-top:12vh;border:1px dashed var(--line)!important;padding:36px!important}.action-dock{position:fixed;bottom:0;left:270px;right:300px;z-index:10;padding:13px 22px 18px;background:rgba(12,12,11,.96);backdrop-filter:blur(15px);border-top:1px solid var(--line)}.action-state{display:flex;justify-content:space-between;margin-bottom:9px;font:500 7px var(--mono);letter-spacing:.11em;color:var(--muted)}.action-state i{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:7px;background:#555}.action-state i.ready{background:var(--acid)}.action-state i.busy{background:#f3a85e}.action-dock textarea{width:100%;height:70px;resize:none;padding:14px;background:#11110f;border:1px solid #383833;color:var(--ink);font:13px/1.5 var(--body)}.dock-actions{display:flex;justify-content:space-between;align-items:center;margin-top:8px}.dock-actions small{font:500 7px var(--mono);color:var(--muted)}.dock-actions>div{display:flex;gap:8px}.acid-button,.ghost-button,.force-button{min-height:38px;padding:0 15px;border:0;background:var(--acid);color:#090909;font:600 8px var(--mono);letter-spacing:.08em}.ghost-button{border:1px solid var(--line);background:transparent;color:var(--ink)}button:disabled{opacity:.38}.spectator-note{margin:12px 0;color:var(--muted);font-size:11px}.readiness,.members,.ai-order,.mechanics-note{padding:22px;border-bottom:1px solid var(--line)}.readiness p{font:500 8px var(--mono);color:var(--muted)}.readiness b{font-size:18px;color:var(--ink)}.readiness>div{height:5px;background:#252522}.readiness>div i{display:block;height:100%;background:var(--acid);transition:width .25s}.members article{display:flex;gap:10px;align-items:center;margin-top:16px}.members article>i{width:6px;height:6px;border-radius:50%;background:#555}.members article>i.ready{background:var(--acid)}.members span{display:flex;flex-direction:column}.members b{font:600 9px var(--display)}.members em{font:500 7px var(--mono);color:var(--muted);text-transform:uppercase;font-style:normal;margin-top:4px}.ai-order>p{font-size:10px;color:var(--muted)}.ai-order>div{display:flex;gap:12px;margin-top:17px}.ai-order>div>b{font:500 8px var(--mono);color:var(--acid)}.ai-order>div>span{font:600 10px var(--display)}.ai-order span small{display:block;margin-top:4px;color:var(--muted);font:400 8px var(--body)}.force-button{width:calc(100% - 44px);height:48px;margin:22px}.mechanics-note{font-size:9px;line-height:1.5;color:var(--muted)}.mechanics-note i{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--acid);margin-right:7px}.sync-error{display:flex;justify-content:space-between;gap:20px;margin:0 clamp(26px,5vw,72px);padding:12px 14px;border:1px solid #6a3a31;background:#251613;color:#ffb29f;font:500 9px var(--mono)}.sync-error button{border:0;background:none;color:inherit;font:inherit}.state-page{min-height:calc(100vh - 76px);display:grid;place-content:center;justify-items:center;text-align:center;padding:30px}.state-page h1{font:600 clamp(34px,7vw,76px) var(--display);margin:10px}.state-page>p{max-width:520px;color:var(--muted)}.state-page .eyebrow{color:var(--acid);font:500 9px var(--mono)}.state-page>div{display:flex;align-items:center;gap:18px;margin-top:20px}.state-page a{color:var(--muted);font:500 9px var(--mono)}.spinner{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--acid);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:1080px){.campaign-layout{grid-template-columns:230px 1fr}.control-panel{display:none}.action-dock{left:230px;right:0}}@media(max-width:720px){.campaign-layout{display:block;height:auto}.party-panel{max-height:240px;border-right:0;border-bottom:1px solid var(--line)}.sheet,.party-member:not(:first-of-type){display:none}.chronicle{min-height:calc(100vh - 76px)}.action-dock{left:0}.timeline{padding:0 18px}.scene-head{padding-left:18px;padding-right:18px}.scene-head>span{display:none}.dock-actions{align-items:flex-start;gap:8px}.dock-actions>small{display:none}}
|
||
</style>
|
||
<style scoped>
|
||
.invite-box{padding:22px;border-bottom:1px solid var(--line)}
|
||
.invite-box>small{font:500 7px var(--mono);color:var(--acid);letter-spacing:.14em}
|
||
.invite-box p{font-size:9px;line-height:1.5;color:var(--muted)}
|
||
.invite-box input{width:100%;margin:8px 0;padding:9px;border:1px solid var(--line);background:#090909;color:var(--muted);font:7px var(--mono)}
|
||
.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>
|