Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
29
apps/web/pages/auth/callback.vue
Normal file
29
apps/web/pages/auth/callback.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
const { restore } = useDngAuth()
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const session = await restore()
|
||||
if (!session) throw new Error('The sign-in link is invalid or expired.')
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination, { replace: true })
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Could not complete sign-in.'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="callback noise">
|
||||
<AppMark />
|
||||
<p v-if="!error">VERIFYING YOUR SIGNAL…</p>
|
||||
<template v-else><p class="error">{{ error }}</p><NuxtLink to="/">REQUEST A NEW LINK</NuxtLink></template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.callback{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px}.callback p{font:500 10px var(--mono);letter-spacing:.16em;color:var(--muted)}.callback .error{max-width:420px;color:#ff9d85;text-align:center}.callback a{color:var(--acid);font:600 9px var(--mono)}
|
||||
</style>
|
||||
292
apps/web/pages/campaign/[id].vue
Normal file
292
apps/web/pages/campaign/[id].vue
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const { worlds } = useDemo()
|
||||
interface CampaignRow {
|
||||
id: string
|
||||
title: string
|
||||
current_scene: string
|
||||
status: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const { api } = useDngApi()
|
||||
const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
const campaigns = ref<CampaignRow[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const visibleCampaigns = computed(() => filter.value === 'active'
|
||||
? campaigns.value.filter(campaign => campaign.status === 'active')
|
||||
: campaigns.value)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await api<{ campaigns: CampaignRow[] }>('/api/v1/campaigns')
|
||||
campaigns.value = result.campaigns
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
error.value = value.data?.statusMessage ?? value.message ?? 'Could not load your campaigns.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,13 +40,15 @@ const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
|
||||
<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>1 / 5 ALPHA WORLDS</span>
|
||||
<span>{{ campaigns.length }} / 5 ALPHA CAMPAIGNS</span>
|
||||
</div>
|
||||
|
||||
<section class="world-grid">
|
||||
<NuxtLink v-for="world in worlds" :key="world.title" to="/campaign/demo" class="world-card active-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>ACTIVE CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>{{ world.genre }}</small><h2>{{ world.title }}</h2><p>{{ world.premise }}</p><div><b>ROUND 03</b><span>2 PARTY MEMBERS</span></div></div>
|
||||
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
||||
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/">SIGN IN AGAIN</NuxtLink></p>
|
||||
<NuxtLink v-for="campaign in visibleCampaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`" class="world-card active-world">
|
||||
<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 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>
|
||||
@@ -32,5 +61,5 @@ const filter = ref<'all' | 'active' | 'drafts'>('all')
|
||||
</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:1.35fr .65fr;gap:18px}.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:.9fr 1.1fr}.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}.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:850px){.dash-head{align-items:start;flex-direction:column}.world-grid{grid-template-columns:1fr}.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}}
|
||||
.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}}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
const { signedIn } = useDemo()
|
||||
const email = ref('founder@example.com')
|
||||
const { session, restore, requestMagicLink, signInAnonymously } = useDngAuth()
|
||||
const email = ref('')
|
||||
const notice = ref('')
|
||||
const guestError = ref('')
|
||||
const sending = ref(false)
|
||||
const entering = ref(false)
|
||||
|
||||
function enterDemo() {
|
||||
signedIn.value = true
|
||||
navigateTo('/dashboard')
|
||||
onMounted(() => void restore())
|
||||
|
||||
async function enterAlpha() {
|
||||
if (entering.value) return
|
||||
entering.value = true
|
||||
notice.value = ''
|
||||
guestError.value = ''
|
||||
try {
|
||||
await restore()
|
||||
if (!session.value) await signInAnonymously()
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination)
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
const rawMessage = error.data?.msg ?? error.data?.message ?? error.message ?? ''
|
||||
guestError.value = /anonymous.*(disabled|sign.?in)/i.test(rawMessage)
|
||||
? 'Guest access is disabled in Supabase Auth settings.'
|
||||
: /database error|saving new user/i.test(rawMessage)
|
||||
? 'The Supabase database migration is incomplete. Apply bootstrap.sql.'
|
||||
: rawMessage || 'Guest access is unavailable right now.'
|
||||
} finally {
|
||||
entering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function requestAccess() {
|
||||
notice.value = `Invite request saved for ${email.value}. Demo access is available now.`
|
||||
async function requestAccess() {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
notice.value = ''
|
||||
try {
|
||||
await requestMagicLink(email.value)
|
||||
notice.value = `Magic link sent to ${email.value}. Check your inbox.`
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
notice.value = error.data?.msg ?? error.data?.message ?? error.message ?? 'This email is not on the alpha allowlist.'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,7 +53,7 @@ function requestAccess() {
|
||||
<div class="landing noise">
|
||||
<header class="landing-nav">
|
||||
<AppMark />
|
||||
<span class="alpha-tag">INVITE-ONLY ALPHA</span>
|
||||
<span class="alpha-tag">PRIVATE ALPHA</span>
|
||||
</header>
|
||||
|
||||
<main class="hero">
|
||||
@@ -26,9 +62,10 @@ function requestAccess() {
|
||||
<h1>STORIES THAT<br><em>WAIT FOR NO ONE.</em></h1>
|
||||
<p class="lede">Build an original universe with an AI coauthor. Gather real players and persistent AI companions. Take your turn when life allows—the Groundkeeper remembers everything.</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-acid" @click="enterDemo">ENTER THE ALPHA <span>↗</span></button>
|
||||
<button class="btn btn-acid" :disabled="entering" @click="enterAlpha">{{ entering ? 'OPENING…' : session ? 'OPEN DASHBOARD' : 'ENTER THE ALPHA' }} <span>↗</span></button>
|
||||
<a href="#how" class="btn btn-ghost">SEE HOW IT WORKS</a>
|
||||
</div>
|
||||
<p v-if="guestError" class="guest-error" role="alert">{{ guestError }}</p>
|
||||
<div class="trust-row">
|
||||
<span>PRIVATE WORLDS</span><i />
|
||||
<span>SERVER-OWNED DICE</span><i />
|
||||
@@ -51,11 +88,11 @@ function requestAccess() {
|
||||
<article><b>01</b><h2>DESCRIBE THE IMPOSSIBLE</h2><p>A coauthor asks sharp questions, then turns your idea into a playable private world.</p></article>
|
||||
<article><b>02</b><h2>ASSEMBLE YOUR PARTY</h2><p>Invite friends, add AI companions, and decide who can step in when someone is away.</p></article>
|
||||
<article><b>03</b><h2>ACT ON YOUR TIME</h2><p>The Groundkeeper resolves each shared round only when the party is ready.</p></article>
|
||||
<form @submit.prevent="requestAccess"><label for="email">REQUEST AN INVITE</label><div><input id="email" v-model="email" type="email" required><button>→</button></div><small>{{ notice || 'We only email about alpha access.' }}</small></form>
|
||||
<form @submit.prevent="requestAccess"><label for="email">OPTIONAL EMAIL SIGN-IN</label><div><input id="email" v-model="email" type="email" autocomplete="email" placeholder="you@example.com" required><button :disabled="sending">{{ sending ? '…' : '→' }}</button></div><small>{{ notice || 'No email is needed. Use this only for an invited email account.' }}</small></form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.guest-error{max-width:620px;margin:14px 0 0;color:#ff9d85;font:500 10px/1.5 var(--mono);letter-spacing:.04em}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn:disabled{opacity:.7;cursor:wait}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
|
||||
</style>
|
||||
|
||||
38
apps/web/pages/join/[token].vue
Normal file
38
apps/web/pages/join/[token].vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { session, restore } = useDngAuth()
|
||||
const { api } = useDngApi()
|
||||
const state = ref<'joining' | 'signin' | 'error'>('joining')
|
||||
const message = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const token = String(route.params.token ?? '')
|
||||
await restore()
|
||||
if (!session.value) {
|
||||
localStorage.setItem('dng-post-auth-redirect', route.fullPath)
|
||||
state.value = 'signin'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await api<{ campaignId: string }>('/api/v1/invites/join', { method: 'POST', body: { token } })
|
||||
await navigateTo(`/campaign/${result.campaignId}`, { replace: true })
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { statusMessage?: string }; message?: string }
|
||||
message.value = error.data?.statusMessage ?? error.message ?? 'This invite is invalid or expired.'
|
||||
state.value = 'error'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="join noise">
|
||||
<AppMark />
|
||||
<div v-if="state === 'joining'" class="card"><small>PARTY INVITE</small><h1>JOINING CAMPAIGN…</h1><p>Verifying your seat at the table.</p></div>
|
||||
<div v-else-if="state === 'signin'" class="card"><small>PARTY INVITE</small><h1>ENTER THE ALPHA FIRST</h1><p>Your invite is saved. Guest access needs no email; after entering, this campaign will open automatically.</p><NuxtLink to="/">ENTER THE ALPHA →</NuxtLink></div>
|
||||
<div v-else class="card"><small>INVITE ERROR</small><h1>THE DOOR STAYED SHUT</h1><p>{{ message }}</p><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:30px;padding:24px}.card{width:min(560px,calc(100vw - 40px));padding:42px;border:1px solid var(--line);background:#0d0d0c}.card small{font:600 8px var(--mono);letter-spacing:.16em;color:var(--acid)}.card h1{font:600 clamp(26px,5vw,45px)/1.05 var(--display);letter-spacing:-.05em}.card p{color:var(--muted);line-height:1.7}.card a{display:inline-flex;margin-top:20px;padding:15px 18px;background:var(--acid);color:#080808;text-decoration:none;font:600 9px var(--mono)}
|
||||
</style>
|
||||
@@ -1,48 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorldStarter } from '@dng/shared'
|
||||
|
||||
const { worlds, activeWorld } = useDemo()
|
||||
const step = ref<'chat' | 'generating' | 'preview'>('chat')
|
||||
const input = ref('A science-fiction mystery about a dead relay sending messages from the future.')
|
||||
const answers = reactive({ tone: '', conflict: '', role: '' })
|
||||
const questionIndex = ref(0)
|
||||
const questions = [
|
||||
{ key: 'tone', label: 'What should the story feel like?', options: ['Tense & uncanny', 'Bold & adventurous', 'Melancholy & intimate'] },
|
||||
{ key: 'conflict', label: 'What pressure drives the opening?', options: ['A ticking clock', 'A fragile alliance', 'A dangerous discovery'] },
|
||||
{ key: 'role', label: 'Who are the players in this world?', options: ['A freelance crew', 'Reluctant investigators', 'Agents of a fading power'] },
|
||||
]
|
||||
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 }
|
||||
|
||||
const { api } = useDngApi()
|
||||
const stage = ref<Stage>('seed')
|
||||
const busy = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
const seed = ref('A science-fiction mystery about a dead relay sending messages from the future.')
|
||||
const answers = reactive<Record<string, string>>({})
|
||||
const currentQuestion = ref<DynamicQuestion | null>(null)
|
||||
const answerCount = ref(0)
|
||||
const draft = ref<WorldStarter | null>(null)
|
||||
const retryAction = ref<'begin' | 'respond' | 'generate'>('begin')
|
||||
const pendingAnswer = ref<{ key: string; value: string } | null>(null)
|
||||
const messages = ref<Message[]>([
|
||||
{ id: 'welcome', role: 'coauthor', body: 'Give me the first impossible sentence. I’ll ask a few focused questions, then turn it into a private world your party can enter tonight.' },
|
||||
])
|
||||
|
||||
function answerFor(key: string) {
|
||||
return answers[key as keyof typeof answers]
|
||||
const displayedQuestion = computed(() => currentQuestion.value ? { key: currentQuestion.value.id, label: currentQuestion.value.label, options: currentQuestion.value.options } : null)
|
||||
const progress = computed(() => stage.value === 'preview' || stage.value === 'confirming' ? 100 : stage.value === 'generating' ? 85 : stage.value === 'seed' ? 10 : 20 + Math.round((Math.min(answerCount.value, 5) / 5) * 55))
|
||||
|
||||
function addMessage(role: Message['role'], body: string, label?: string) {
|
||||
messages.value.push({ id: `${Date.now()}-${messages.value.length}`, role, body, label })
|
||||
}
|
||||
|
||||
function begin() {
|
||||
if (!input.value.trim()) return
|
||||
questionIndex.value = 1
|
||||
async function begin() {
|
||||
const prompt = seed.value.trim()
|
||||
if (!prompt || stage.value !== 'seed' || busy.value) return
|
||||
busy.value = true
|
||||
retryAction.value = 'begin'
|
||||
try {
|
||||
const result = await api<{ session: { id: string } }>('/api/v1/coauthor/sessions', { method: 'POST', body: { message: prompt } })
|
||||
sessionId.value = result.session.id
|
||||
addMessage('player', prompt)
|
||||
stage.value = 'questions'
|
||||
retryAction.value = 'respond'
|
||||
await requestNextQuestion()
|
||||
} catch (error) {
|
||||
addMessage('status', errorText(error) || 'Could not start a private coauthor session. Sign in and try again.', 'ERROR')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function choose(key: string, value: string) {
|
||||
;(answers as Record<string, string>)[key] = value
|
||||
if (questionIndex.value < questions.length) questionIndex.value += 1
|
||||
async function answerQuestion(key: string, value: string) {
|
||||
if (stage.value !== 'questions' || currentQuestion.value?.id !== key || busy.value || !sessionId.value) return
|
||||
busy.value = true
|
||||
pendingAnswer.value = { key, value }
|
||||
try {
|
||||
await api(`/api/v1/coauthor/sessions/${sessionId.value}/messages`, {
|
||||
method: 'POST', body: { content: value },
|
||||
})
|
||||
answers[key] = value
|
||||
addMessage('player', value)
|
||||
currentQuestion.value = null
|
||||
answerCount.value = Math.min(answerCount.value + 1, 5)
|
||||
pendingAnswer.value = null
|
||||
retryAction.value = 'respond'
|
||||
await requestNextQuestion()
|
||||
} catch (error) {
|
||||
addMessage('status', errorText(error) || 'That answer could not be saved. Try again.', 'ERROR')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function requestNextQuestion() {
|
||||
if (!sessionId.value) return
|
||||
const result = await api<RespondResult>(`/api/v1/coauthor/sessions/${sessionId.value}/respond`, { method: 'POST' })
|
||||
if (result.readyToGenerate || answerCount.value >= 5) {
|
||||
currentQuestion.value = null
|
||||
await generate()
|
||||
return
|
||||
}
|
||||
if (!result.question) throw new Error('The coauthor did not return its next question.')
|
||||
currentQuestion.value = result.question
|
||||
addMessage('coauthor', result.question.label, `COAUTHOR · QUESTION ${Math.min(answerCount.value + 1, 5)} OF UP TO 5`)
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
if (error && typeof error === 'object') {
|
||||
const value = error as { data?: { statusMessage?: string; message?: string }; statusMessage?: string; message?: string }
|
||||
return value.data?.statusMessage || value.data?.message || value.statusMessage || value.message
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
step.value = 'generating'
|
||||
if (!sessionId.value) return
|
||||
stage.value = 'generating'
|
||||
retryAction.value = 'generate'
|
||||
addMessage('status', 'Building premise, location, key people, factions, hidden pressure, and an opening scene…', 'GENERATING')
|
||||
try {
|
||||
draft.value = await $fetch<WorldStarter>('/api/worlds/generate', { method: 'POST', body: { prompt: input.value, answers } })
|
||||
} catch {
|
||||
draft.value = structuredClone(activeWorld.value)
|
||||
await api(`/api/v1/coauthor/sessions/${sessionId.value}/generate`, { method: 'POST' })
|
||||
const deadline = Date.now() + 90_000
|
||||
while (Date.now() < deadline) {
|
||||
const result = await api<{ session: { status: string; generatedWorld?: unknown; generated_world?: unknown } }>(`/api/v1/coauthor/sessions/${sessionId.value}`)
|
||||
if (result.session.status === 'ready') {
|
||||
draft.value = (result.session.generatedWorld ?? result.session.generated_world) as WorldStarter
|
||||
break
|
||||
}
|
||||
if (result.session.status === 'failed') throw new Error('The coauthor job failed. Try generation again.')
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000))
|
||||
}
|
||||
if (!draft.value) throw new Error('World generation is taking too long. Your session is saved; try again shortly.')
|
||||
messages.value.pop()
|
||||
addMessage('status', `“${draft.value.title}” is ready for your review. Every visible field remains editable.`, 'READY')
|
||||
stage.value = 'preview'
|
||||
} catch (error) {
|
||||
messages.value.pop()
|
||||
addMessage('status', errorText(error) || 'The coauthor could not generate this world. Your answers are safe—try again.', 'ERROR')
|
||||
stage.value = 'questions'
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 700))
|
||||
step.value = 'preview'
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!draft.value) return
|
||||
activeWorld.value = draft.value
|
||||
if (!worlds.value.some(world => world.title === draft.value!.title)) worlds.value.unshift(draft.value)
|
||||
navigateTo('/campaign/demo')
|
||||
function retry() {
|
||||
const last = messages.value.at(-1)
|
||||
if (last?.role === 'status' && last.label === 'ERROR') messages.value.pop()
|
||||
if (retryAction.value === 'begin') begin()
|
||||
else if (retryAction.value === 'generate') generate()
|
||||
else if (pendingAnswer.value && currentQuestion.value) answerQuestion(pendingAnswer.value.key, pendingAnswer.value.value)
|
||||
else requestNextQuestion().catch(error => addMessage('status', errorText(error) || 'The coauthor could not continue. Try again.', 'ERROR'))
|
||||
}
|
||||
|
||||
function revise() {
|
||||
stage.value = 'seed'
|
||||
sessionId.value = null
|
||||
currentQuestion.value = null
|
||||
answerCount.value = 0
|
||||
pendingAnswer.value = null
|
||||
for (const key of Object.keys(answers)) delete answers[key]
|
||||
draft.value = null
|
||||
addMessage('coauthor', 'Let’s reshape it from the opening sentence. I’ll adapt the next questions to your new direction.', 'COAUTHOR · REVISION')
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!draft.value || !sessionId.value || stage.value === 'confirming') return
|
||||
stage.value = 'confirming'
|
||||
try {
|
||||
const confirmed = await api<{ worldId: string }>(`/api/v1/coauthor/sessions/${sessionId.value}/confirm`, {
|
||||
method: 'POST', body: { world: draft.value },
|
||||
})
|
||||
const created = await api<{ campaignId: string }>('/api/v1/campaigns', {
|
||||
method: 'POST', body: { worldId: confirmed.worldId, title: draft.value.title },
|
||||
})
|
||||
await navigateTo(`/campaign/${created.campaignId}`)
|
||||
} catch (error) {
|
||||
addMessage('status', errorText(error) || 'The world could not be confirmed. Your draft is still safe.', 'ERROR')
|
||||
stage.value = 'preview'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -51,50 +161,32 @@ function confirm() {
|
||||
<div class="forge noise">
|
||||
<aside>
|
||||
<p class="step-label">CREATION PROTOCOL</p>
|
||||
<div class="progress"><i :style="{ width: `${progress}%` }" /></div>
|
||||
<ol>
|
||||
<li :class="{ active: step==='chat' }"><b>01</b><span>Seed idea<small>Say what cannot exist yet.</small></span></li>
|
||||
<li :class="{ active: questionIndex>0 && step==='chat' }"><b>02</b><span>Shape the signal<small>Tone, pressure, player role.</small></span></li>
|
||||
<li :class="{ active: step==='generating' }"><b>03</b><span>Generate structure<small>A playable starting kit.</small></span></li>
|
||||
<li :class="{ active: step==='preview' }"><b>04</b><span>Review & launch<small>You remain the final author.</small></span></li>
|
||||
<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 === '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>
|
||||
<div class="boundary"><span>13+ BOUNDARY</span><p>Dark themes are welcome. Explicit sexual content and extreme graphic violence are not.</p></div>
|
||||
</aside>
|
||||
|
||||
<main v-if="step==='chat'" class="coauthor">
|
||||
<main v-if="stage !== 'preview' && stage !== 'confirming'" class="coauthor">
|
||||
<p class="kicker">COAUTHOR / SESSION 01</p>
|
||||
<h1>WHAT SHOULD<br>WE BUILD<span>?</span></h1>
|
||||
<div class="chat-line ai"><small>COAUTHOR</small><p>Give me the first impossible sentence. I’ll help turn it into a world your party can enter tonight.</p></div>
|
||||
<form class="seed" @submit.prevent="begin"><textarea v-model="input" aria-label="World idea" rows="3"/><button>BEGIN <span>→</span></button></form>
|
||||
|
||||
<div v-for="(question, index) in questions" v-show="questionIndex > index" :key="question.key" class="question-block">
|
||||
<div class="chat-line ai"><small>COAUTHOR · {{ String(index+2).padStart(2,'0') }}</small><p>{{ question.label }}</p></div>
|
||||
<div class="choices">
|
||||
<button v-for="option in question.options" :key="option" :class="{selected:answerFor(question.key)===option}" @click="choose(question.key, option)">{{ option }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="Object.values(answers).every(Boolean)" class="generate" @click="generate">GENERATE STARTING WORLD <span>↗</span></button>
|
||||
<h1>BUILD THE<br>IMPOSSIBLE<span>.</span></h1>
|
||||
<CoauthorConversation :messages="messages" :question="displayedQuestion" :selected-answer="currentQuestion ? answers[currentQuestion.id] : undefined" :busy="stage === 'generating' || busy" @answer="answerQuestion" @retry="retry" />
|
||||
<form v-if="stage === 'seed'" class="seed" @submit.prevent="begin">
|
||||
<textarea v-model="seed" aria-label="World idea" rows="3" maxlength="1200" autofocus />
|
||||
<button :disabled="!seed.trim() || busy">{{ busy ? 'SAVING…' : 'BEGIN' }} <span>→</span></button>
|
||||
</form>
|
||||
<div v-if="stage === 'generating'" class="scanner" aria-label="Generating world"><i /><i /><i /><span>D&G</span></div>
|
||||
</main>
|
||||
|
||||
<main v-else-if="step==='generating'" class="generating">
|
||||
<div class="scanner"><i/><i/><i/><span>D&G</span></div><p>ASSEMBLING A PLAYABLE WORLD</p><small>Premise · Location · 3 NPCs · 2 factions · Hidden pressure</small>
|
||||
</main>
|
||||
|
||||
<main v-else-if="draft" class="preview">
|
||||
<div class="preview-top"><div><p class="kicker">GENERATED STARTING KIT</p><input v-model="draft.title" aria-label="World title"></div><button @click="step='chat'">← REVISE INPUT</button></div>
|
||||
<div class="preview-grid">
|
||||
<section class="premise"><label>PREMISE</label><textarea v-model="draft.premise" rows="6"/><div><span>{{ draft.genre }}</span><span>{{ draft.tone }}</span></div></section>
|
||||
<section><label>STARTING LOCATION</label><h2>{{ draft.startingLocation.name }}</h2><p>{{ draft.startingLocation.summary }}</p></section>
|
||||
<section class="wide"><label>KEY PEOPLE</label><div class="entity-row"><article v-for="npc in draft.npcs" :key="npc.id"><small>NPC</small><h3>{{ npc.name }}</h3><p>{{ npc.summary }}</p></article></div></section>
|
||||
<section class="wide"><label>FACTIONS</label><div class="entity-row factions"><article v-for="faction in draft.factions" :key="faction.id"><small>FACTION</small><h3>{{ faction.name }}</h3><p>{{ faction.summary }}</p></article></div></section>
|
||||
<section><label>OPENING HOOK</label><p>{{ draft.hook }}</p></section>
|
||||
<section><label>CONTENT BOUNDARIES</label><ul><li v-for="boundary in draft.contentBoundaries" :key="boundary">{{ boundary }}</li></ul></section>
|
||||
</div>
|
||||
<div class="confirm-bar"><p><b>PRIVATE WORLD</b><span>Only invited campaign members can see it.</span></p><button @click="confirm">CONFIRM & ENTER WORLD <span>→</span></button></div>
|
||||
</main>
|
||||
<CoauthorWorldPreview v-else-if="draft" v-model="draft" @revise="revise" @confirm="confirm" />
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.forge{min-height:calc(100vh - 76px);display:grid;grid-template-columns:300px 1fr}.forge>aside{padding:50px 34px;border-right:1px solid var(--line);display:flex;flex-direction:column}.step-label,.kicker{font:500 9px var(--mono);letter-spacing:.18em;color:var(--acid)}ol{list-style:none;padding:28px 0;margin:0}li{display:flex;gap:18px;padding:20px 0;color:#4d4d48}li>b{font:500 9px var(--mono)}li span{font:600 10px var(--display);letter-spacing:.04em}li small{display:block;margin-top:7px;font:400 10px/1.4 var(--body);color:#55554f}li.active{color:var(--ink)}li.active b{color:var(--acid)}li.active small{color:var(--muted)}.boundary{margin-top:auto;padding:18px;border:1px solid var(--line)}.boundary span{font:600 8px var(--mono);color:var(--acid)}.boundary p{font-size:10px;line-height:1.5;color:var(--muted)}.coauthor,.preview{padding:clamp(44px,6vw,82px);max-width:1100px;width:100%}.coauthor h1{font:600 clamp(42px,5vw,72px)/.95 var(--display);letter-spacing:-.06em;margin:16px 0 54px}.coauthor h1 span{color:var(--acid)}.chat-line{max-width:680px;border-left:2px solid var(--acid);padding:3px 20px;margin:26px 0 18px}.chat-line small{font:500 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.chat-line p{font-size:15px;line-height:1.65;color:#d0d0ca}.seed{display:flex;align-items:stretch;max-width:760px}.seed textarea,.preview textarea,.preview input{width:100%;resize:none;background:#10100f;border:1px solid var(--line);color:var(--ink);padding:18px;font:500 14px/1.6 var(--body)}.seed button,.generate,.confirm-bar button{border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono);letter-spacing:.12em}.choices{display:flex;gap:9px;flex-wrap:wrap;margin-left:20px}.choices button{padding:12px 14px;border:1px solid var(--line);background:#10100f;color:var(--muted);font:500 9px var(--mono)}.choices button.selected{border-color:var(--acid);color:var(--acid)}.generate{margin-top:42px;min-height:52px}.generating{display:grid;place-content:center;text-align:center}.scanner{position:relative;width:250px;height:250px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;margin:0 auto 34px;animation:rotate 8s linear infinite}.scanner:after{content:"";position:absolute;inset:35px;border:1px dashed var(--acid-dim);border-radius:50%}.scanner i{position:absolute;width:7px;height:7px;background:var(--acid);border-radius:50%}.scanner i:nth-child(1){top:10px}.scanner i:nth-child(2){left:20px;bottom:55px}.scanner i:nth-child(3){right:5px;top:90px}.scanner span{font:600 26px var(--display);color:var(--acid)}.generating p{font:600 11px var(--mono);letter-spacing:.16em}.generating small{color:var(--muted)}@keyframes rotate{to{transform:rotate(360deg)}}.preview{max-width:1300px}.preview-top{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:34px}.preview-top input{border:0;border-bottom:1px solid var(--line);font:600 clamp(30px,4vw,54px) var(--display);letter-spacing:-.05em;padding:12px 0;background:transparent}.preview-top button{background:transparent;color:var(--muted);border:0;font:500 8px var(--mono)}.preview-grid{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid var(--line);border-left:1px solid var(--line)}.preview-grid>section{padding:28px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.preview-grid .wide{grid-column:1/-1}.preview-grid label{font:600 8px var(--mono);letter-spacing:.15em;color:var(--acid)}.preview-grid h2{font:600 22px var(--display);margin:20px 0 12px}.preview-grid p{font-size:12px;line-height:1.7;color:var(--muted)}.premise div{display:flex;gap:8px;margin-top:14px}.premise div span{padding:7px 9px;border:1px solid var(--line);font:500 8px var(--mono);color:var(--muted)}.entity-row{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);margin-top:22px}.entity-row article{background:#0d0d0c;padding:20px}.entity-row small{font:500 7px var(--mono);color:var(--acid)}.entity-row h3{font:600 13px var(--display)}.factions{grid-template-columns:1fr 1fr}.preview-grid ul{padding-left:18px;color:var(--muted);font-size:12px}.confirm-bar{position:sticky;bottom:0;display:flex;justify-content:space-between;align-items:center;padding:18px 22px;background:#11110f;border:1px solid var(--line);margin-top:24px}.confirm-bar p{margin:0;display:flex;flex-direction:column;font:600 8px var(--mono);color:var(--acid)}.confirm-bar p span{margin-top:5px;color:var(--muted);font-weight:400}.confirm-bar button{min-height:48px}@media(max-width:850px){.forge{grid-template-columns:1fr}.forge>aside{display:none}.coauthor,.preview{padding:40px 20px}.preview-grid{grid-template-columns:1fr}.preview-grid .wide{grid-column:auto}.entity-row,.factions{grid-template-columns:1fr}.confirm-bar{align-items:stretch;flex-direction:column;gap:14px}}
|
||||
.forge{min-height:calc(100vh - 76px);display:grid;grid-template-columns:300px 1fr}.forge>aside{padding:50px 34px;border-right:1px solid var(--line);display:flex;flex-direction:column}.step-label,.kicker{font:500 9px var(--mono);letter-spacing:.18em;color:var(--acid)}.progress{height:3px;margin-top:22px;background:#252522}.progress i{display:block;height:100%;background:var(--acid);transition:width .35s ease}ol{list-style:none;padding:20px 0;margin:0}li{display:flex;gap:18px;padding:18px 0;color:#4d4d48}li>b{font:500 9px var(--mono)}li span{font:600 10px var(--display);letter-spacing:.04em}li small{display:block;margin-top:7px;font:400 10px/1.4 var(--body);color:#55554f}li.active{color:var(--ink)}li.active b,li.done b{color:var(--acid)}li.active small{color:var(--muted)}li.done:not(.active){color:#77776f}.boundary{margin-top:auto;padding:18px;border:1px solid var(--line)}.boundary span{font:600 8px var(--mono);color:var(--acid)}.boundary p{font-size:10px;line-height:1.5;color:var(--muted)}.coauthor{padding:clamp(44px,6vw,82px);max-width:1000px;width:100%}.coauthor h1{font:600 clamp(42px,5vw,72px)/.95 var(--display);letter-spacing:-.06em;margin:16px 0 44px}.coauthor h1 span{color:var(--acid)}.seed{display:flex;align-items:stretch;max-width:780px;margin-top:22px}.seed textarea{width:100%;resize:vertical;background:#10100f;border:1px solid var(--line);color:var(--ink);padding:18px;font:500 14px/1.6 var(--body)}.seed button{border:0;background:var(--acid);color:#080808;padding:0 24px;font:600 9px var(--mono);letter-spacing:.12em}.seed button:disabled{opacity:.4}.scanner{position:relative;width:180px;height:180px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;margin:40px auto 0;animation:rotate 8s linear infinite}.scanner:after{content:"";position:absolute;inset:28px;border:1px dashed var(--acid-dim);border-radius:50%}.scanner i{position:absolute;width:6px;height:6px;background:var(--acid);border-radius:50%}.scanner i:nth-child(1){top:8px}.scanner i:nth-child(2){left:13px;bottom:40px}.scanner i:nth-child(3){right:3px;top:64px}.scanner span{font:600 20px var(--display);color:var(--acid)}@keyframes rotate{to{transform:rotate(360deg)}}@media(max-width:850px){.forge{grid-template-columns:1fr}.forge>aside{display:none}.coauthor{box-sizing:border-box;padding:40px 20px}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user