feat: make demo story screen interactive
This commit is contained in:
+104
-7
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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("娜娜");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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
@@ -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%);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user