feat: make demo story screen interactive

This commit is contained in:
Codex
2026-07-28 13:06:49 +08:00
parent ea209c9b69
commit 77b42a7879
6 changed files with 877 additions and 46 deletions
+104 -7
View File
@@ -1,25 +1,122 @@
import { flushPromises, mount } from "@vue/test-utils"; import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import App from "./App.vue"; import App from "./App.vue";
async function mountLoadedApp(): Promise<VueWrapper> {
const wrapper = mount(App, { attachTo: document.body });
await flushPromises();
return wrapper;
}
describe("App", () => { describe("App", () => {
it("renders the canonical demo PlayerView", async () => { it("renders the canonical demo PlayerView", async () => {
const wrapper = mount(App); const wrapper = await mountLoadedApp();
await flushPromises();
expect(wrapper.text()).toContain("听娜娜讲故事"); expect(wrapper.text()).toContain("听娜娜讲故事");
expect(wrapper.text()).toContain("娜娜"); expect(wrapper.text()).toContain("娜娜");
expect(wrapper.text()).toContain("雨水沿着废弃站台的铁棚落下");
expect(wrapper.text()).toContain("你真的会在天亮前回来吗?"); expect(wrapper.text()).toContain("你真的会在天亮前回来吗?");
expect(wrapper.get('textarea[aria-label="自由输入"]')).toBeDefined(); expect(wrapper.get('textarea[aria-label="自由输入"]')).toBeDefined();
wrapper.unmount();
}); });
it("copies an editable suggestion into the composer", async () => { it("copies an editable suggestion without submitting it", async () => {
const wrapper = mount(App); const wrapper = await mountLoadedApp();
await flushPromises(); const startingNode = wrapper.get(".statusline span").text();
await wrapper.get(".suggestions button").trigger("click"); await wrapper.get(".suggestions button").trigger("click");
const composer = wrapper.get<HTMLTextAreaElement>("textarea"); const composer = wrapper.get<HTMLTextAreaElement>("textarea");
expect(composer.element.value.length).toBeGreaterThan(0); expect(composer.element.value.length).toBeGreaterThan(0);
expect(wrapper.get(".statusline span").text()).toBe(startingNode);
expect(wrapper.find(".turn-status").text()).toBe("");
expect(wrapper.find(".beat--action").exists()).toBe(false);
wrapper.unmount();
});
it("submits a speak_or_act intent, clears the draft, and exposes a busy state", async () => {
const wrapper = await mountLoadedApp();
const composer = wrapper.get<HTMLTextAreaElement>('textarea[aria-label="自由输入"]');
await composer.setValue("我答应你,天亮前一定回来。");
await wrapper.get("form.composer").trigger("submit");
expect(composer.element.value).toBe("");
expect(wrapper.get(".send-button").text()).toBe("发送中…");
expect(composer.attributes("disabled")).toBeDefined();
expect(wrapper.get(".continue-button").attributes("disabled")).toBeDefined();
await vi.waitFor(() => {
expect(wrapper.text()).toContain("我答应你,天亮前一定回来。");
expect(wrapper.get(".statusline span").text()).toBe("node_002");
});
expect(wrapper.get(".send-button").text()).toBe("发送");
wrapper.unmount();
});
it("submits Continue through the same turn path with no player speech", async () => {
const wrapper = await mountLoadedApp();
await wrapper.get(".continue-button").trigger("click");
expect(wrapper.get(".continue-button").text()).toBe("继续中…");
await vi.waitFor(() => {
expect(wrapper.text()).toContain("一阵风越过站台");
});
expect(wrapper.findAll(".beat--action")).toHaveLength(0);
expect(wrapper.get(".statusline span").text()).toBe("node_002");
wrapper.unmount();
});
it("opens mutually exclusive, PlayerView-only record and relationship panels", async () => {
const wrapper = await mountLoadedApp();
const [recordsButton, relationshipButton] = wrapper.findAll(".top-actions button");
await recordsButton.trigger("click");
expect(wrapper.findAll('[role="dialog"]')).toHaveLength(1);
expect(wrapper.get("#records-panel").text()).toContain("旧手电筒");
expect(wrapper.get("#records-panel").text()).toContain("停摆的站钟");
expect(wrapper.get("#records-panel").text()).toContain("还没有被接受的许诺");
await relationshipButton.trigger("click");
expect(wrapper.findAll('[role="dialog"]')).toHaveLength(1);
expect(wrapper.find("#records-panel").exists()).toBe(false);
const relationshipText = wrapper.get("#relationship-panel").text();
expect(relationshipText).toContain("好感");
expect(relationshipText).toContain("渐暖");
expect(relationshipText).not.toMatch(/\d/);
for (const forbidden of ["roll", "target", "hidden_facts", "world_flags", "checks"]) {
expect(wrapper.text()).not.toContain(forbidden);
}
wrapper.unmount();
});
it("selects a rewind node without claiming to persist a branch", async () => {
const wrapper = await mountLoadedApp();
const historyButton = wrapper.findAll(".top-actions button")[2];
await historyButton.trigger("click");
expect(wrapper.find(".rewind-placeholder").exists()).toBe(false);
const nodeButton = wrapper.get(".history-list button");
await nodeButton.trigger("click");
expect(nodeButton.attributes("aria-pressed")).toBe("true");
expect(wrapper.get(".rewind-placeholder").text()).toContain("从这里继续(尚未接入)");
expect(wrapper.get(".rewind-placeholder button").attributes("disabled")).toBeDefined();
expect(wrapper.get(".rewind-placeholder").text()).toContain("不会更改当前线路");
wrapper.unmount();
});
it("closes the active panel with Escape", async () => {
const wrapper = await mountLoadedApp();
await wrapper.findAll(".top-actions button")[0].trigger("click");
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
await wrapper.vm.$nextTick();
expect(wrapper.find('[role="dialog"]').exists()).toBe(false);
wrapper.unmount();
}); });
}); });
+260 -15
View File
@@ -1,19 +1,110 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { useDemo } from "./app/useDemo"; import { useDemo } from "./app/useDemo";
const { appInfo, error, latestBeat, loading, pack, playerView } = useDemo(); type PanelName = "records" | "relationship" | "history";
const {
appInfo,
busy,
error,
lastSubmittedIntent,
loading,
pack,
playerView,
submitTurn,
turnError
} = useDemo();
const draft = ref(""); const draft = ref("");
const activePanel = ref<PanelName | null>(null);
const selectedHistoryNode = ref<string | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);
const sceneLabel = computed(() => { const sceneLabel = computed(() => {
if (!playerView.value) return "载入场景"; if (!playerView.value) return "载入场景";
return `${playerView.value.sceneTitle} · ${pack.value?.displayName ?? ""}`; return `${playerView.value.sceneTitle} · ${pack.value?.displayName ?? ""}`;
}); });
const recentBeats = computed(() => playerView.value?.beats.slice(-5) ?? []);
const relationshipLabels = {
affinity: "好感",
trust: "信赖",
hope: "希望",
respect: "尊重",
intimacy: "亲密",
attachment: "依恋"
} as const;
const relationshipBandLabels = {
distant: "疏远",
guarded: "保留",
warming: "渐暖",
close: "亲近",
bonded: "深厚"
} as const;
const certaintyLabels = {
suspected: "推测",
reported: "听闻",
confirmed: "确认"
} as const;
const promiseStatusLabels = {
proposed: "待回应",
accepted: "已接受",
fulfilled: "已履行",
broken: "已背弃",
released: "已解除",
impossible: "无法完成"
} as const;
const placementLabels = {
bag: "随身",
worn: "穿戴",
hand: "手持",
scene: "场景中",
hidden: "收起"
} as const;
function useSuggestion(suggestion: { draft: string }) { function useSuggestion(suggestion: { draft: string }) {
draft.value = suggestion.draft; draft.value = suggestion.draft;
} }
async function openPanel(panel: PanelName) {
activePanel.value = activePanel.value === panel ? null : panel;
if (activePanel.value) {
await nextTick();
closeButton.value?.focus();
}
}
function closePanel() {
activePanel.value = null;
}
async function sendDraft() {
const input = draft.value.trim();
if (!input || busy.value) return;
draft.value = "";
await submitTurn("speak_or_act", input);
}
async function continueStory() {
if (busy.value || !playerView.value?.canContinue) return;
await submitTurn("continue", "");
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape" && activePanel.value) {
closePanel();
}
}
onMounted(() => window.addEventListener("keydown", handleEscape));
onBeforeUnmount(() => window.removeEventListener("keydown", handleEscape));
</script> </script>
<template> <template>
@@ -25,9 +116,30 @@ function useSuggestion(suggestion: { draft: string }) {
</div> </div>
<div class="scene-label">{{ sceneLabel }}</div> <div class="scene-label">{{ sceneLabel }}</div>
<nav class="top-actions" aria-label="故事工具"> <nav class="top-actions" aria-label="故事工具">
<button type="button">持有物</button> <button
<button type="button">关系</button> type="button"
<button type="button">回溯</button> aria-controls="records-panel"
:aria-expanded="activePanel === 'records'"
@click="openPanel('records')"
>
持有物
</button>
<button
type="button"
aria-controls="relationship-panel"
:aria-expanded="activePanel === 'relationship'"
@click="openPanel('relationship')"
>
关系
</button>
<button
type="button"
aria-controls="history-panel"
:aria-expanded="activePanel === 'history'"
@click="openPanel('history')"
>
回溯
</button>
</nav> </nav>
</header> </header>
@@ -52,12 +164,13 @@ function useSuggestion(suggestion: { draft: string }) {
<div class="chapter-chip">第一幕 · 雨夜车站</div> <div class="chapter-chip">第一幕 · 雨夜车站</div>
<article class="dialogue-panel"> <article class="dialogue-panel" aria-label="最近演出">
<div class="speaker-row"> <ol class="beat-list">
<strong>{{ latestBeat?.speaker ?? playerView.characterName }}</strong> <li v-for="beat in recentBeats" :key="beat.id" :class="`beat beat--${beat.kind}`">
<span>雨声很近钟声还没有响</span> <strong v-if="beat.speaker">{{ beat.speaker }}</strong>
</div> <p>{{ beat.text }}</p>
<p>{{ latestBeat?.text }}</p> </li>
</ol>
</article> </article>
<div class="suggestions" aria-label="行动建议"> <div class="suggestions" aria-label="行动建议">
@@ -65,30 +178,162 @@ function useSuggestion(suggestion: { draft: string }) {
v-for="suggestion in playerView.suggestions" v-for="suggestion in playerView.suggestions"
:key="suggestion.id" :key="suggestion.id"
type="button" type="button"
:disabled="busy"
@click="useSuggestion(suggestion)" @click="useSuggestion(suggestion)"
> >
{{ suggestion.label }} {{ suggestion.label }}
</button> </button>
</div> </div>
<form class="composer" @submit.prevent> <form class="composer" aria-label="玩家输入" @submit.prevent="sendDraft">
<textarea <textarea
v-model="draft" v-model="draft"
aria-label="自由输入" aria-label="自由输入"
placeholder="说些什么,或者描述你的行动……" placeholder="说些什么,或者描述你的行动……"
rows="2" rows="2"
:disabled="busy"
/> />
<button class="continue-button" type="button">继续</button> <button
<button class="send-button" type="submit" :disabled="draft.trim().length === 0"> class="continue-button"
发送 type="button"
:disabled="busy || !playerView.canContinue"
@click="continueStory"
>
{{ busy && lastSubmittedIntent === "continue" ? "继续中…" : "继续" }}
</button>
<button class="send-button" type="submit" :disabled="busy || draft.trim().length === 0">
{{ busy && lastSubmittedIntent === "speak_or_act" ? "发送中…" : "发送" }}
</button> </button>
</form> </form>
<p class="turn-status" aria-live="polite">
<span v-if="busy">故事正在回应请稍候</span>
<span v-else-if="turnError">请求未完成{{ turnError }}</span>
</p>
<footer class="statusline"> <footer class="statusline">
<span>{{ playerView.nodeId }}</span> <span>{{ playerView.nodeId }}</span>
<span>{{ playerView.branchId }}</span> <span>{{ playerView.branchId }}</span>
<span>关系与判定已隐藏</span> <span>关系与判定已隐藏</span>
</footer> </footer>
<div v-if="activePanel" class="panel-scrim" @click.self="closePanel">
<aside
:id="`${activePanel}-panel`"
class="side-panel"
role="dialog"
aria-modal="true"
:aria-labelledby="`${activePanel}-title`"
>
<header class="panel-header">
<div>
<span class="panel-eyebrow">当前故事</span>
<h2 :id="`${activePanel}-title`">
{{
activePanel === "records"
? "随身记录"
: activePanel === "relationship"
? `${playerView.characterName}的关系`
: "故事回溯"
}}
</h2>
</div>
<button ref="closeButton" class="panel-close" type="button" aria-label="关闭面板" @click="closePanel">
×
</button>
</header>
<div v-if="activePanel === 'records'" class="panel-content records-panel">
<section aria-labelledby="inventory-title">
<h3 id="inventory-title">持有物</h3>
<ul v-if="playerView.inventory.length" class="card-list">
<li v-for="item in playerView.inventory" :key="item.instanceId">
<div class="card-title">
<strong>{{ item.name }}</strong>
<span>{{ placementLabels[item.placement] }} · ×{{ item.quantity }}</span>
</div>
<p>{{ item.description }}</p>
<small>状态{{ item.condition }}</small>
</li>
</ul>
<p v-else class="empty-copy">目前没有随身持有物</p>
</section>
<section aria-labelledby="knowledge-title">
<h3 id="knowledge-title">已知线索</h3>
<ul v-if="playerView.knowledge.length" class="card-list">
<li v-for="record in playerView.knowledge" :key="record.id">
<div class="card-title">
<strong>{{ record.title }}</strong>
<span>{{ certaintyLabels[record.certainty] }}</span>
</div>
<p>{{ record.summary }}</p>
</li>
</ul>
<p v-else class="empty-copy">还没有确认的线索</p>
</section>
<section aria-labelledby="promises-title">
<h3 id="promises-title">重要许诺</h3>
<ul v-if="playerView.promises.length" class="card-list">
<li v-for="promise in playerView.promises" :key="promise.id">
<div class="card-title">
<strong>{{ promise.content }}</strong>
<span>{{ promiseStatusLabels[promise.status] }}</span>
</div>
<small>{{ promise.weight === "major" ? "重要许诺" : "普通许诺" }}</small>
</li>
</ul>
<p v-else class="empty-copy">你们之间还没有被接受的许诺</p>
</section>
</div>
<div v-else-if="activePanel === 'relationship'" class="panel-content relationship-panel">
<p class="panel-intro">关系只呈现她此刻对你的大致感受不显示精确数值</p>
<dl class="relationship-list">
<div
v-for="(label, key) in relationshipLabels"
:key="key"
class="relationship-row"
>
<dt>{{ label }}</dt>
<dd :data-band="playerView.relationship[key]">
{{ relationshipBandLabels[playerView.relationship[key]] }}
</dd>
</div>
</dl>
<p class="panel-note">
{{
playerView.relationship.updatedAtNode
? "最近在一个场景节点后发生了变化。"
: "当前场景尚未结算新的关系变化。"
}}
</p>
</div>
<div v-else class="panel-content history-panel">
<p class="panel-intro">选择一个故事节点查看回溯入口当前原型不会创建或保存新分支</p>
<ol class="history-list">
<li v-for="node in playerView.history" :key="node.id">
<button
type="button"
:class="{ selected: selectedHistoryNode === node.id }"
:aria-pressed="selectedHistoryNode === node.id"
@click="selectedHistoryNode = node.id"
>
<span>{{ node.label }}</span>
<small>{{ node.isCurrent ? "当前节点" : "旧节点" }}</small>
</button>
</li>
</ol>
<div v-if="selectedHistoryNode" class="rewind-placeholder" aria-live="polite">
<span>已选择{{ selectedHistoryNode }}</span>
<button type="button" disabled>从这里继续尚未接入</button>
<small>这里只展示入口不会更改当前线路</small>
</div>
</div>
</aside>
</div>
</section> </section>
</main> </main>
</template> </template>
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import initialView from "../../fixtures/player-view/initial.json";
import type { PlayerView, TurnRequest } from "@contracts";
import { submitDemoTurn } from "./turnAdapter";
function request(intent: TurnRequest["intent"], input: string): TurnRequest {
return {
storyId: initialView.storyId,
branchId: initialView.branchId,
expectedNodeId: initialView.nodeId,
actionId: `test_${intent}`,
intent,
input
};
}
describe("local turn adapter", () => {
it("preserves literal player input without inventing additional player speech", async () => {
const literalInput = "我把手电筒放在长椅上。";
const result = await submitDemoTurn(
request("speak_or_act", literalInput),
initialView as PlayerView
);
const playerBeats = result.playerView.beats.filter((beat) => beat.speaker === "你");
expect(playerBeats).toHaveLength(1);
expect(playerBeats[0]?.text).toBe(literalInput);
});
it("accepts an empty Continue intent without creating a player beat", async () => {
const result = await submitDemoTurn(request("continue", ""), initialView as PlayerView);
expect(result.committedNodeId).toBe("node_002");
expect(result.playerView.beats.some((beat) => beat.speaker === "你")).toBe(false);
expect(result.playerView.beats.at(-1)?.speaker).toBe("娜娜");
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { PlayerView, PresentationBeat, TurnRequest, TurnResult } from "@contracts";
const DEMO_LATENCY_MS = 120;
function nextNodeId(currentNodeId: string): string {
const match = currentNodeId.match(/^(.*?)(\d+)$/);
if (!match) return `${currentNodeId}_next`;
const [, prefix, digits] = match;
return `${prefix}${String(Number(digits) + 1).padStart(digits.length, "0")}`;
}
function replyBeats(request: TurnRequest): PresentationBeat[] {
if (request.intent === "continue") {
return [
{
id: `${request.actionId}_rain`,
kind: "narration",
speaker: null,
text: "一阵风越过站台,雨点敲在铁棚上,短暂盖过了远处的水声。",
visual: null
},
{
id: `${request.actionId}_nana`,
kind: "dialogue",
speaker: "娜娜",
text: "如果你还没想好,就先听一会儿雨吧。",
visual: {
character: "nana",
expression: "guarded",
pose: null,
scene: null
}
}
];
}
return [
{
id: `${request.actionId}_player`,
kind: "action",
speaker: "你",
// The demo repeats only the player's literal input. It never invents player speech or intent.
text: request.input,
visual: null
},
{
id: `${request.actionId}_nana`,
kind: "dialogue",
speaker: "娜娜",
text: "娜娜静静听完,攥着外套的手稍微松开了一些。“我知道了。”",
visual: {
character: "nana",
expression: "uneasy",
pose: "holding_coat",
scene: null
}
}
];
}
/**
* Browser-only M0 adapter. It deliberately mirrors the future runtime boundary:
* App -> TurnRequest -> TurnResult. No Tauri command or persisted branch is implied.
*/
export async function submitDemoTurn(
request: TurnRequest,
currentView: PlayerView
): Promise<TurnResult> {
await new Promise((resolve) => window.setTimeout(resolve, DEMO_LATENCY_MS));
const committedNodeId = nextNodeId(currentView.nodeId);
const previousHistory = currentView.history.map((node) => ({ ...node, isCurrent: false }));
return {
committedNodeId,
playerView: {
...currentView,
nodeId: committedNodeId,
beats: [...currentView.beats, ...replyBeats(request)].slice(-8),
history: [
...previousHistory,
{
id: committedNodeId,
parentId: currentView.nodeId,
branchId: currentView.branchId,
label: request.intent === "continue" ? "雨声中的停顿" : "回应娜娜",
isCurrent: true
}
]
}
};
}
+42 -6
View File
@@ -1,8 +1,9 @@
import { computed, onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts"; import type { AppInfo, DemoPackSummary, PlayerView, TurnIntent, TurnRequest } from "@contracts";
import { getAppInfo, getDemoPackSummary, getDemoPlayerView } from "./bridge"; import { getAppInfo, getDemoPackSummary, getDemoPlayerView } from "./bridge";
import { submitDemoTurn } from "./turnAdapter";
export function useDemo() { export function useDemo() {
const appInfo = ref<AppInfo | null>(null); const appInfo = ref<AppInfo | null>(null);
@@ -10,8 +11,9 @@ export function useDemo() {
const playerView = ref<PlayerView | null>(null); const playerView = ref<PlayerView | null>(null);
const loading = ref(true); const loading = ref(true);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const busy = ref(false);
const latestBeat = computed(() => playerView.value?.beats.at(-1) ?? null); const turnError = ref<string | null>(null);
const lastSubmittedIntent = ref<TurnIntent | null>(null);
onMounted(async () => { onMounted(async () => {
try { try {
@@ -27,12 +29,46 @@ export function useDemo() {
} }
}); });
async function submitTurn(intent: "speak_or_act", input: string): Promise<void>;
async function submitTurn(intent: "continue", input?: ""): Promise<void>;
async function submitTurn(intent: "speak_or_act" | "continue", input = ""): Promise<void> {
const currentView = playerView.value;
const normalizedInput = input.trim();
if (!currentView || busy.value || (intent === "speak_or_act" && !normalizedInput)) return;
const request: TurnRequest = {
storyId: currentView.storyId,
branchId: currentView.branchId,
expectedNodeId: currentView.nodeId,
actionId: `demo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
intent,
input: intent === "continue" ? "" : normalizedInput
};
busy.value = true;
turnError.value = null;
lastSubmittedIntent.value = intent;
try {
const result = await submitDemoTurn(request, currentView);
playerView.value = result.playerView;
} catch (reason) {
turnError.value = reason instanceof Error ? reason.message : String(reason);
} finally {
busy.value = false;
}
}
return { return {
appInfo, appInfo,
busy,
error, error,
latestBeat, lastSubmittedIntent,
loading, loading,
pack, pack,
playerView playerView,
submitTurn,
turnError
}; };
} }
+339 -18
View File
@@ -96,6 +96,18 @@ button {
color: rgb(245 241 234 / 72%); color: rgb(245 241 234 / 72%);
} }
.top-actions button[aria-expanded="true"] {
color: #f1c7b5;
background: rgb(231 184 164 / 12%);
border-color: rgb(231 184 164 / 42%);
}
button:focus-visible,
textarea:focus-visible {
outline: 2px solid #f0c1ae;
outline-offset: 3px;
}
.stage { .stage {
position: relative; position: relative;
height: calc(100% - 58px); height: calc(100% - 58px);
@@ -254,8 +266,9 @@ button {
bottom: 116px; bottom: 116px;
left: 7%; left: 7%;
z-index: 10; z-index: 10;
min-height: 126px; height: 154px;
padding: 20px 26px; padding: 17px 24px;
overflow: hidden;
background: linear-gradient(100deg, rgb(13 17 27 / 94%), rgb(24 26 34 / 84%)); background: linear-gradient(100deg, rgb(13 17 27 / 94%), rgb(24 26 34 / 84%));
border: 1px solid rgb(255 255 255 / 13%); border: 1px solid rgb(255 255 255 / 13%);
border-radius: 14px; border-radius: 14px;
@@ -263,30 +276,54 @@ button {
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
.speaker-row { .beat-list {
display: flex; display: flex;
align-items: baseline; height: 100%;
gap: 16px; flex-direction: column;
gap: 10px;
margin: 0;
padding: 0 8px 0 0;
overflow-y: auto;
list-style: none;
scrollbar-color: rgb(231 184 164 / 30%) transparent;
} }
.speaker-row strong { .beat {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: baseline;
gap: 12px;
}
.beat strong {
color: #f0c4b2; color: #f0c4b2;
font-family: "Noto Serif SC", serif; font-family: "Noto Serif SC", serif;
font-size: 19px; font-size: 15px;
font-weight: 600;
} }
.speaker-row span { .beat p {
color: rgb(241 237 231 / 40%); margin: 0;
font-size: 12px; color: rgb(250 247 242 / 88%);
}
.dialogue-panel p {
max-width: 72ch;
margin: 14px 0 0;
color: rgb(250 247 242 / 90%);
font-family: "Noto Serif SC", "Songti SC", serif; font-family: "Noto Serif SC", "Songti SC", serif;
font-size: 17px; font-size: 15px;
line-height: 1.75; line-height: 1.7;
}
.beat--narration,
.beat--system {
display: block;
}
.beat--narration p,
.beat--system p {
color: rgb(245 241 234 / 58%);
font-style: italic;
}
.beat--action strong,
.beat--action p {
color: rgb(211 224 231 / 70%);
} }
.suggestions { .suggestions {
@@ -327,6 +364,11 @@ button {
border-radius: 12px; border-radius: 12px;
} }
.composer textarea:disabled,
.composer button:disabled {
cursor: wait;
}
.composer button { .composer button {
min-width: 72px; min-width: 72px;
cursor: pointer; cursor: pointer;
@@ -350,6 +392,17 @@ button {
opacity: 0.42; opacity: 0.42;
} }
.turn-status {
position: absolute;
right: 18%;
bottom: 18px;
z-index: 14;
margin: 0;
color: rgb(241 196 178 / 72%);
font-size: 11px;
pointer-events: none;
}
.statusline { .statusline {
position: absolute; position: absolute;
right: 8%; right: 8%;
@@ -375,3 +428,271 @@ button {
.state-screen--error { .state-screen--error {
color: #f1b5ad; color: #f1b5ad;
} }
.panel-scrim {
position: absolute;
inset: 0;
z-index: 30;
display: flex;
justify-content: flex-end;
background: rgb(4 6 11 / 46%);
backdrop-filter: blur(4px);
}
.side-panel {
display: flex;
width: min(480px, 48vw);
min-width: 400px;
height: 100%;
flex-direction: column;
color: rgb(247 243 237 / 88%);
background:
linear-gradient(160deg, rgb(31 35 46 / 98%), rgb(14 17 26 / 98%)),
#151923;
border-left: 1px solid rgb(255 255 255 / 12%);
box-shadow: -28px 0 70px rgb(0 0 0 / 42%);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 26px 28px 19px;
border-bottom: 1px solid rgb(255 255 255 / 9%);
}
.panel-header h2 {
margin: 5px 0 0;
font-family: "Noto Serif SC", "Songti SC", serif;
font-size: 23px;
font-weight: 500;
}
.panel-eyebrow {
color: rgb(239 196 177 / 65%);
font-size: 10px;
letter-spacing: 0.18em;
}
.panel-close {
display: grid;
width: 34px;
height: 34px;
padding: 0;
place-items: center;
cursor: pointer;
color: rgb(245 241 234 / 62%);
font-size: 24px;
line-height: 1;
background: transparent;
border: 1px solid rgb(255 255 255 / 11%);
border-radius: 50%;
}
.panel-content {
flex: 1;
padding: 24px 28px 34px;
overflow-y: auto;
}
.panel-content section + section {
margin-top: 30px;
}
.panel-content h3 {
margin: 0 0 12px;
color: rgb(245 241 234 / 56%);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.14em;
}
.card-list,
.history-list {
display: grid;
gap: 9px;
margin: 0;
padding: 0;
list-style: none;
}
.card-list li {
padding: 15px 16px;
background: rgb(255 255 255 / 4%);
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 11px;
}
.card-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.card-title strong {
font-family: "Noto Serif SC", "Songti SC", serif;
font-size: 15px;
font-weight: 500;
}
.card-title span,
.card-list small {
color: rgb(240 193 173 / 66%);
font-size: 10px;
}
.card-list p {
margin: 8px 0;
color: rgb(245 241 234 / 62%);
font-size: 13px;
line-height: 1.65;
}
.empty-copy,
.panel-intro,
.panel-note {
color: rgb(245 241 234 / 48%);
font-size: 13px;
line-height: 1.7;
}
.empty-copy {
margin: 0;
padding: 16px;
text-align: center;
background: rgb(255 255 255 / 3%);
border-radius: 10px;
}
.panel-intro {
margin: 0 0 24px;
}
.relationship-list {
display: grid;
gap: 12px;
margin: 0;
}
.relationship-row {
display: grid;
grid-template-columns: 90px 1fr;
align-items: center;
min-height: 52px;
padding: 0 16px;
background: rgb(255 255 255 / 4%);
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 10px;
}
.relationship-row dt {
color: rgb(245 241 234 / 56%);
font-size: 13px;
letter-spacing: 0.1em;
}
.relationship-row dd {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin: 0;
color: #efc3b0;
font-family: "Noto Serif SC", "Songti SC", serif;
}
.relationship-row dd::before {
width: 52px;
height: 3px;
content: "";
background: linear-gradient(90deg, rgb(239 195 176 / 18%), rgb(239 195 176 / 75%));
border-radius: 999px;
}
.panel-note {
margin: 20px 0 0;
}
.history-list {
position: relative;
}
.history-list::before {
position: absolute;
top: 16px;
bottom: 16px;
left: 12px;
width: 1px;
content: "";
background: rgb(231 184 164 / 28%);
}
.history-list li {
position: relative;
padding-left: 28px;
}
.history-list li::before {
position: absolute;
top: 18px;
left: 8px;
z-index: 1;
width: 9px;
height: 9px;
content: "";
background: #252a36;
border: 2px solid rgb(231 184 164 / 70%);
border-radius: 50%;
}
.history-list button {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 13px 15px;
cursor: pointer;
color: rgb(245 241 234 / 72%);
text-align: left;
background: rgb(255 255 255 / 3%);
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 9px;
}
.history-list button.selected {
color: #f2c8b6;
background: rgb(231 184 164 / 9%);
border-color: rgb(231 184 164 / 35%);
}
.history-list small {
color: rgb(245 241 234 / 38%);
}
.rewind-placeholder {
display: grid;
gap: 9px;
margin-top: 22px;
padding: 16px;
background: rgb(7 9 15 / 36%);
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 10px;
}
.rewind-placeholder span {
color: rgb(245 241 234 / 55%);
font-size: 11px;
}
.rewind-placeholder button {
padding: 11px 14px;
color: rgb(26 20 20 / 64%);
background: rgb(231 184 164 / 54%);
border: 0;
border-radius: 9px;
}
.rewind-placeholder small {
color: rgb(245 241 234 / 34%);
}