<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?: { id?: string; display_name?: string; avatar_url?: string | null } | 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)}" /><NuxtLink class="member-profile" :to="`/profile/${member.user_id}?campaign=${campaignId}`"><span class="member-avatar"><b>{{ initials(member.profile?.display_name || 'Adventurer') }}</b><img v-if="member.profile?.avatar_url" :src="member.profile.avatar_url" alt=""></span><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></span></NuxtLink><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></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}
.members article{display:grid;grid-template-columns:6px minmax(0,1fr);align-items:center}
.member-profile{display:grid;grid-template-columns:30px minmax(0,1fr);gap:9px;align-items:center;color:var(--ink);text-decoration:none}
.member-profile>span:last-child{display:flex;min-width:0;flex-direction:column}
.member-avatar{position:relative;overflow:hidden;display:grid!important;place-items:center;width:30px;height:30px;border:1px solid var(--line);background:#171714;color:var(--acid)}
.member-avatar>b{font:600 7px var(--mono)!important}
.member-avatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.members .member-profile b{overflow-wrap:anywhere}
.members .takeover-toggle{grid-column:2;margin-left:39px}
</style>
