Initial commit: AI-driven visual novel scaffold
- Monorepo (pnpm workspace): apps/web + packages/{types,ai-client,engine}
- Next.js 16 web app with three-stage AI orchestration
- Three independently configurable providers: text LLM, image generator, vision model
- Warm minimalist editorial UI design
- One-click Vercel deploy ready
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { takeTurn } from "@dada/engine";
|
||||
import type { InteractRequest } from "@dada/types";
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadEngineConfig } from "@/lib/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: InteractRequest;
|
||||
try {
|
||||
body = (await req.json()) as InteractRequest;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.session || !body.prevImageBase64 || !body.click) {
|
||||
return NextResponse.json(
|
||||
{ error: "session, prevImageBase64, click are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = loadEngineConfig();
|
||||
const result = await takeTurn(config, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { startSession } from "@dada/engine";
|
||||
import type { StartRequest } from "@dada/types";
|
||||
import { NextResponse } from "next/server";
|
||||
import { loadEngineConfig } from "@/lib/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: StartRequest;
|
||||
try {
|
||||
body = (await req.json()) as StartRequest;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.worldSetting?.trim() || !body.styleGuide?.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "worldSetting and styleGuide are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = loadEngineConfig();
|
||||
const result = await startSession(config, body);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-feature-settings: "ss01", "kern", "liga";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image:
|
||||
radial-gradient(rgba(133, 79, 37, 0.025) 1px, transparent 1px),
|
||||
radial-gradient(rgba(133, 79, 37, 0.018) 1px, transparent 1px);
|
||||
background-size: 28px 28px, 38px 38px;
|
||||
background-position: 0 0, 14px 19px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgb(217 122 46 / 0.28);
|
||||
color: #2d1810;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: rgb(168 105 59 / 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.hairline {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(45, 24, 16, 0.18) 18%,
|
||||
rgba(45, 24, 16, 0.18) 82%,
|
||||
transparent
|
||||
);
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.hairline-full {
|
||||
height: 1px;
|
||||
background: rgba(45, 24, 16, 0.14);
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
}
|
||||
|
||||
.smallcaps {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.32em;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dada-ripple {
|
||||
0% {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 0.95;
|
||||
}
|
||||
100% {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dada — AI Visual Novel",
|
||||
description:
|
||||
"An open-source visual novel where every frame is generated by AI.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin=""
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@300;400;500&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body className="bg-cream-50 text-clay-900 font-sans antialiased min-h-screen">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
import { CustomForm } from "@/components/CustomForm";
|
||||
|
||||
export default function NewPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-clay-900 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
Dada
|
||||
</Link>
|
||||
<span className="text-[10px] smallcaps text-clay-500">
|
||||
Compose · a · world
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="px-6 md:px-16 pt-20 md:pt-32 pb-20 md:pb-24 flex-1">
|
||||
<div className="grid grid-cols-12 gap-8 md:gap-16 max-w-6xl">
|
||||
<div className="col-span-12 md:col-span-4 animate-fade-in">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-6">
|
||||
Ⅳ · Untitled
|
||||
</p>
|
||||
<h1 className="font-serif text-[44px] md:text-[64px] text-clay-900 leading-[0.96] mb-8">
|
||||
Write
|
||||
<br />
|
||||
<em className="italic text-clay-600">two</em>
|
||||
<br />
|
||||
paragraphs.
|
||||
</h1>
|
||||
<div className="hairline w-12 mb-6" />
|
||||
<p className="font-serif text-base text-clay-700 leading-[1.7]">
|
||||
The first sketches the world your story unfolds in. The second
|
||||
describes how the world should look — its medium, its mood, its
|
||||
grain.
|
||||
</p>
|
||||
<p className="font-serif italic text-sm text-clay-500 mt-5 leading-relaxed">
|
||||
Both fields accept any language. Specificity rewards specificity.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-7 md:col-start-6">
|
||||
<CustomForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="px-6 md:px-16 pb-8">
|
||||
<div className="hairline-full w-full mb-4" />
|
||||
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
|
||||
<span>MIT · MMXXVI</span>
|
||||
<span className="num">Ⅰ · Ⅳ</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Link from "next/link";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import { PresetCard } from "@/components/PresetCard";
|
||||
|
||||
const ORDINALS = ["Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ"];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="px-6 md:px-16 pt-7 md:pt-10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-[10px] smallcaps text-clay-700 font-medium">
|
||||
Dada
|
||||
</span>
|
||||
<span className="hairline w-10 hidden md:block" />
|
||||
<span className="text-[10px] smallcaps text-clay-500 hidden md:block">
|
||||
Frame · by · Frame
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-5 text-[10px] smallcaps text-clay-600">
|
||||
<a
|
||||
href="https://github.com"
|
||||
className="hover:text-clay-900 transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<span className="text-clay-300">·</span>
|
||||
<a href="#about" className="hover:text-clay-900 transition-colors">
|
||||
About
|
||||
</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section className="px-6 md:px-16 pt-20 md:pt-36 pb-20 md:pb-28">
|
||||
<div className="grid grid-cols-12 gap-8">
|
||||
<div className="col-span-12 md:col-span-7 animate-fade-in">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-8">
|
||||
An open-source experiment · MMXXVI
|
||||
</p>
|
||||
<h1 className="font-serif font-light text-[56px] md:text-[104px] leading-[0.94] text-clay-900 tracking-tight">
|
||||
Every{" "}
|
||||
<em className="italic font-light text-clay-600">frame</em>
|
||||
<br />
|
||||
is painted on
|
||||
<br />
|
||||
<span className="text-ember-500 italic font-light">demand.</span>
|
||||
</h1>
|
||||
<p className="mt-10 md:mt-14 max-w-md font-serif text-lg md:text-xl text-clay-700 leading-[1.65]">
|
||||
Dada is a visual novel where the <em>entire</em> interface — scene,
|
||||
dialogue, choices — is rendered by an AI, one frame at a time. You
|
||||
click. It paints. The story unfolds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside className="col-span-12 md:col-span-4 md:col-start-9 mt-8 md:mt-0 flex md:items-end">
|
||||
<div className="space-y-3">
|
||||
<div className="hairline w-12" />
|
||||
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
|
||||
“It is impossible to step into the same river twice.
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-600 text-base md:text-[17px] leading-relaxed max-w-[280px]">
|
||||
It is impossible to play the same Dada twice.”
|
||||
</p>
|
||||
<p className="text-[10px] smallcaps text-clay-500 pt-2">
|
||||
— README · v0.1
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="px-6 md:px-16">
|
||||
<div className="hairline-full w-full" />
|
||||
</div>
|
||||
|
||||
<section className="px-6 md:px-16 pt-14 md:pt-20 pb-16 md:pb-24">
|
||||
<div className="flex items-baseline justify-between mb-8 md:mb-10">
|
||||
<h2 className="text-[10px] smallcaps text-clay-700 font-medium">
|
||||
Four Doors
|
||||
</h2>
|
||||
<p className="text-[10px] smallcaps text-clay-500 hidden md:block">
|
||||
Choose a world · or compose your own
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1">
|
||||
{PRESETS.map((p, i) => (
|
||||
<PresetCard key={p.id} preset={p} ordinal={ORDINALS[i]!} />
|
||||
))}
|
||||
|
||||
<Link
|
||||
href="/new"
|
||||
className="group block w-full py-10 md:py-12 border-t border-b border-clay-900/10 hover:border-clay-900/35 transition-[border-color] duration-500"
|
||||
>
|
||||
<div className="flex items-baseline gap-6 md:gap-10">
|
||||
<span className="font-serif italic text-2xl md:text-3xl text-clay-400 group-hover:text-clay-700 transition-colors duration-500 w-8 shrink-0">
|
||||
{ORDINALS[3]}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-serif text-3xl md:text-4xl text-clay-900 leading-tight mb-2.5">
|
||||
Untitled
|
||||
</h3>
|
||||
<p className="text-sm text-clay-600 leading-relaxed max-w-md">
|
||||
Bring your own world. Describe it in your own words.
|
||||
</p>
|
||||
</div>
|
||||
<span className="hidden md:flex items-center gap-3 text-[10px] tracking-[0.4em] text-clay-400 group-hover:text-ember-500 transition-colors duration-500 shrink-0 self-center">
|
||||
COMPOSE
|
||||
<span className="w-7 h-px bg-current transition-all duration-500 group-hover:w-12" />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="about"
|
||||
className="px-6 md:px-16 pb-20 md:pb-28 grid grid-cols-12 gap-8"
|
||||
>
|
||||
<div className="col-span-12 md:col-span-3">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
||||
Colophon · I
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
||||
A small open-source experiment in generative narrative. Self-host on
|
||||
Vercel in a single click.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-3 md:col-start-5">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
||||
Colophon · II
|
||||
</p>
|
||||
<ul className="font-serif text-clay-700 text-base leading-relaxed space-y-1">
|
||||
<li>Story · large language model</li>
|
||||
<li>Image · generative renderer</li>
|
||||
<li>Click · vision interpreter</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="col-span-12 md:col-span-3 md:col-start-9">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">
|
||||
Colophon · III
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
||||
All three are configured separately — bring any OpenAI-compatible
|
||||
endpoint.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="px-6 md:px-16 pb-10 mt-auto">
|
||||
<div className="hairline-full w-full mb-5" />
|
||||
<div className="flex items-center justify-between text-[10px] smallcaps text-clay-500">
|
||||
<span>MIT · MMXXVI</span>
|
||||
<span className="num">Ⅰ · Ⅰ</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef, useState } from "react";
|
||||
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
|
||||
import { PRESETS } from "@/lib/presets";
|
||||
import type {
|
||||
ClickIntent,
|
||||
InteractResponse,
|
||||
Session,
|
||||
StartResponse,
|
||||
StoryFrame,
|
||||
} from "@dada/types";
|
||||
|
||||
function PlayInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("loading-first");
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [imageBase64, setImageBase64] = useState<string | null>(null);
|
||||
const [frame, setFrame] = useState<StoryFrame | null>(null);
|
||||
const [intent, setIntent] = useState<ClickIntent | null>(null);
|
||||
const [pendingClick, setPendingClick] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [turnNum, setTurnNum] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
|
||||
let payload: { worldSetting: string; styleGuide: string } | null = null;
|
||||
const presetId = params.get("preset");
|
||||
|
||||
if (presetId) {
|
||||
const p = PRESETS.find((x) => x.id === presetId);
|
||||
if (p) {
|
||||
payload = { worldSetting: p.worldSetting, styleGuide: p.styleGuide };
|
||||
}
|
||||
} else if (params.get("custom") === "1") {
|
||||
const stored = sessionStorage.getItem("dada:custom");
|
||||
if (stored) {
|
||||
try {
|
||||
payload = JSON.parse(stored);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPayload = payload;
|
||||
|
||||
fetch("/api/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(finalPayload),
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const j = (await r.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(j.error ?? r.statusText);
|
||||
}
|
||||
return r.json() as Promise<StartResponse>;
|
||||
})
|
||||
.then((data) => {
|
||||
setSession({
|
||||
id: data.sessionId,
|
||||
createdAt: Date.now(),
|
||||
worldSetting: finalPayload.worldSetting,
|
||||
styleGuide: finalPayload.styleGuide,
|
||||
history: [{ frame: data.frame }],
|
||||
});
|
||||
setFrame(data.frame);
|
||||
setImageBase64(data.imageBase64);
|
||||
setPhase("ready");
|
||||
setTurnNum(1);
|
||||
})
|
||||
.catch((e) => setError(String(e)));
|
||||
}, [params, router]);
|
||||
|
||||
async function handleClick(click: { x: number; y: number }) {
|
||||
if (phase !== "ready" || !session || !imageBase64) return;
|
||||
setPhase("interacting");
|
||||
setPendingClick(click);
|
||||
setIntent(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/interact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session,
|
||||
prevImageBase64: imageBase64,
|
||||
click,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
const data = (await res.json()) as InteractResponse;
|
||||
|
||||
const updatedHistory = [
|
||||
...data.session.history,
|
||||
{ frame: data.frame },
|
||||
];
|
||||
setSession({ ...data.session, history: updatedHistory });
|
||||
setFrame(data.frame);
|
||||
setImageBase64(data.imageBase64);
|
||||
setIntent(data.intent);
|
||||
setPendingClick(null);
|
||||
setTurnNum((t) => t + 1);
|
||||
setPhase("ready");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setPendingClick(null);
|
||||
setPhase("ready");
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center px-8">
|
||||
<div className="max-w-md text-center animate-fade-in">
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-6">
|
||||
An · error · occurred
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-900 text-lg leading-[1.7] mb-10">
|
||||
{error}
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[10px] smallcaps text-clay-700 hover:text-ember-500 transition-colors inline-flex items-center gap-3"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
Return
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[10px] smallcaps text-clay-600 hover:text-clay-900 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-left text-[9px]" />
|
||||
Dada
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-[10px] smallcaps text-clay-500 num">
|
||||
<span>Frame · {String(turnNum).padStart(3, "0")}</span>
|
||||
<span className="text-clay-300">·</span>
|
||||
<span className="hidden sm:inline truncate max-w-[180px]">
|
||||
{session?.id.slice(2, 14) ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col items-center justify-center px-4 md:px-8 py-6 md:py-10">
|
||||
<PlayCanvas
|
||||
imageBase64={imageBase64}
|
||||
phase={phase}
|
||||
pendingClick={pendingClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
|
||||
<div className="mt-7 md:mt-9 max-w-md w-full text-center min-h-[64px] flex items-center justify-center">
|
||||
{phase === "loading-first" && (
|
||||
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||||
Summoning · the · first · frame
|
||||
</p>
|
||||
)}
|
||||
{phase === "interacting" && (
|
||||
<div className="flex flex-col items-center gap-2 animate-fade-in">
|
||||
<p className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||||
AI · is · painting · the · next · moment
|
||||
</p>
|
||||
<p className="font-serif italic text-clay-400 text-xs">
|
||||
this usually takes 12–20 seconds
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "ready" && intent?.targetLabel && (
|
||||
<p className="font-serif italic text-clay-500 text-base leading-relaxed animate-fade-in max-w-[320px]">
|
||||
<span className="text-[9px] smallcaps not-italic text-clay-400 mr-2 align-middle">
|
||||
Last · move ·
|
||||
</span>
|
||||
<span className="align-middle">{intent.targetLabel}</span>
|
||||
</p>
|
||||
)}
|
||||
{phase === "ready" && !intent && turnNum > 0 && (
|
||||
<p className="text-[10px] smallcaps text-clay-400 animate-fade-in">
|
||||
Click · anywhere · to · respond
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="px-5 md:px-12 pb-6">
|
||||
<div className="text-[9px] smallcaps text-clay-400 text-center num">
|
||||
Ⅰ · Ⅰ
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlayPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<span className="text-[10px] smallcaps text-clay-500 animate-slow-pulse">
|
||||
Loading
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PlayInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user