chore: bootstrap nana-story M0

This commit is contained in:
Codex
2026-07-28 12:51:00 +08:00
commit 3dacc92423
71 changed files with 6840 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import App from "./App.vue";
describe("App", () => {
it("renders the canonical demo PlayerView", async () => {
const wrapper = mount(App);
await flushPromises();
expect(wrapper.text()).toContain("听娜娜讲故事");
expect(wrapper.text()).toContain("娜娜");
expect(wrapper.text()).toContain("你真的会在天亮前回来吗?");
expect(wrapper.get('textarea[aria-label="自由输入"]')).toBeDefined();
});
it("copies an editable suggestion into the composer", async () => {
const wrapper = mount(App);
await flushPromises();
await wrapper.get(".suggestions button").trigger("click");
const composer = wrapper.get<HTMLTextAreaElement>("textarea");
expect(composer.element.value.length).toBeGreaterThan(0);
});
});
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useDemo } from "./app/useDemo";
const { appInfo, error, latestBeat, loading, pack, playerView } = useDemo();
const draft = ref("");
const sceneLabel = computed(() => {
if (!playerView.value) return "载入场景";
return `${playerView.value.sceneTitle} · ${pack.value?.displayName ?? ""}`;
});
function useSuggestion(suggestion: { draft: string }) {
draft.value = suggestion.draft;
}
</script>
<template>
<main class="app-shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<span>{{ appInfo?.name ?? "听娜娜讲故事" }}</span>
</div>
<div class="scene-label">{{ sceneLabel }}</div>
<nav class="top-actions" aria-label="故事工具">
<button type="button">持有物</button>
<button type="button">关系</button>
<button type="button">回溯</button>
</nav>
</header>
<section v-if="loading" class="state-screen">正在载入故事</section>
<section v-else-if="error" class="state-screen state-screen--error">
<strong>内容载入失败</strong>
<span>{{ error }}</span>
</section>
<section v-else-if="playerView" class="stage">
<div class="stage-sky" />
<div class="station-sign">旧青川站</div>
<div class="character" aria-label="娜娜">
<div class="character-hair" />
<div class="character-face">
<i class="eye eye--left" />
<i class="eye eye--right" />
<i class="mouth" />
</div>
<div class="character-coat" />
</div>
<div class="chapter-chip">第一幕 · 雨夜车站</div>
<article class="dialogue-panel">
<div class="speaker-row">
<strong>{{ latestBeat?.speaker ?? playerView.characterName }}</strong>
<span>雨声很近钟声还没有响</span>
</div>
<p>{{ latestBeat?.text }}</p>
</article>
<div class="suggestions" aria-label="行动建议">
<button
v-for="suggestion in playerView.suggestions"
:key="suggestion.id"
type="button"
@click="useSuggestion(suggestion)"
>
{{ suggestion.label }}
</button>
</div>
<form class="composer" @submit.prevent>
<textarea
v-model="draft"
aria-label="自由输入"
placeholder="说些什么,或者描述你的行动……"
rows="2"
/>
<button class="continue-button" type="button">继续</button>
<button class="send-button" type="submit" :disabled="draft.trim().length === 0">
发送
</button>
</form>
<footer class="statusline">
<span>{{ playerView.nodeId }}</span>
<span>{{ playerView.branchId }}</span>
<span>关系与判定已隐藏</span>
</footer>
</section>
</main>
</template>
+42
View File
@@ -0,0 +1,42 @@
import { invoke } from "@tauri-apps/api/core";
import initialView from "../../fixtures/player-view/initial.json";
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts";
function isTauri(): boolean {
return typeof window !== "undefined" && window.__TAURI_INTERNALS__ !== undefined;
}
export async function getAppInfo(): Promise<AppInfo> {
if (!isTauri()) {
return {
name: "听娜娜讲故事",
version: "0.1.0",
contractVersion: 1
};
}
return invoke<AppInfo>("get_app_info");
}
export async function getDemoPlayerView(): Promise<PlayerView> {
if (!isTauri()) {
return initialView as PlayerView;
}
return invoke<PlayerView>("get_demo_player_view");
}
export async function getDemoPackSummary(): Promise<DemoPackSummary> {
if (!isTauri()) {
return {
id: "nana.demo.station",
displayName: "天亮之前",
revision: "0.1.0",
characters: 1,
worldBooks: 1,
personas: 1,
plotModules: 1,
itemSpecs: 1
};
}
return invoke<DemoPackSummary>("get_demo_pack_summary");
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import initialView from "../../fixtures/player-view/initial.json";
describe("PlayerView security boundary", () => {
it("does not expose hidden runtime fields", () => {
const serialized = JSON.stringify(initialView);
for (const forbidden of [
'"roll"',
'"target"',
'"hidden_facts"',
'"world_flags"',
'"checks"',
'"relationship_states"'
]) {
expect(serialized).not.toContain(forbidden);
}
});
it("shows only qualitative relationship bands", () => {
for (const [key, value] of Object.entries(initialView.relationship)) {
if (key === "updatedAtNode") continue;
expect(typeof value).toBe("string");
}
});
});
+38
View File
@@ -0,0 +1,38 @@
import { computed, onMounted, ref } from "vue";
import type { AppInfo, DemoPackSummary, PlayerView } from "@contracts";
import { getAppInfo, getDemoPackSummary, getDemoPlayerView } from "./bridge";
export function useDemo() {
const appInfo = ref<AppInfo | null>(null);
const pack = ref<DemoPackSummary | null>(null);
const playerView = ref<PlayerView | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const latestBeat = computed(() => playerView.value?.beats.at(-1) ?? null);
onMounted(async () => {
try {
[appInfo.value, pack.value, playerView.value] = await Promise.all([
getAppInfo(),
getDemoPackSummary(),
getDemoPlayerView()
]);
} catch (reason) {
error.value = reason instanceof Error ? reason.message : String(reason);
} finally {
loading.value = false;
}
});
return {
appInfo,
error,
latestBeat,
loading,
pack,
playerView
};
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface Window {
__TAURI_INTERNALS__?: unknown;
}
+7
View File
@@ -0,0 +1,7 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import "./styles/main.css";
createApp(App).use(createPinia()).mount("#app");
+377
View File
@@ -0,0 +1,377 @@
:root {
color: #f3f2ef;
background: #0b0e16;
font-family:
Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont,
sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
min-width: 960px;
height: 100%;
min-height: 640px;
margin: 0;
}
button,
textarea {
font: inherit;
}
button {
color: inherit;
}
.app-shell {
width: 100%;
height: 100%;
overflow: hidden;
background: #111522;
}
.topbar {
position: relative;
z-index: 20;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: 58px;
padding: 0 22px;
color: rgb(245 241 234 / 88%);
background: rgb(11 14 22 / 86%);
border-bottom: 1px solid rgb(255 255 255 / 8%);
backdrop-filter: blur(18px);
}
.brand,
.top-actions {
display: flex;
align-items: center;
gap: 12px;
}
.brand {
font-family: "Noto Serif SC", "Songti SC", serif;
letter-spacing: 0.08em;
}
.brand-mark {
display: grid;
width: 30px;
height: 30px;
place-items: center;
color: #e5b7a5;
border: 1px solid rgb(229 183 165 / 48%);
border-radius: 50%;
}
.scene-label {
color: rgb(244 239 232 / 62%);
font-size: 13px;
letter-spacing: 0.12em;
}
.top-actions {
justify-content: flex-end;
}
.top-actions button,
.suggestions button {
cursor: pointer;
background: transparent;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 999px;
}
.top-actions button {
padding: 7px 13px;
color: rgb(245 241 234 / 72%);
}
.stage {
position: relative;
height: calc(100% - 58px);
overflow: hidden;
background:
linear-gradient(180deg, rgb(12 17 31 / 4%), rgb(8 11 19 / 88%)),
linear-gradient(115deg, #18283c 0%, #26364b 46%, #111722 100%);
}
.stage::before,
.stage::after {
position: absolute;
content: "";
pointer-events: none;
}
.stage::before {
inset: 0;
opacity: 0.35;
background:
repeating-linear-gradient(
104deg,
transparent 0 28px,
rgb(210 230 244 / 16%) 29px,
transparent 31px 52px
);
transform: translateY(-15%);
}
.stage::after {
right: -10%;
bottom: 31%;
width: 64%;
height: 28%;
background: linear-gradient(90deg, transparent, rgb(233 172 124 / 20%));
filter: blur(40px);
}
.stage-sky {
position: absolute;
inset: 0 0 44%;
background:
radial-gradient(circle at 68% 30%, rgb(247 194 145 / 18%), transparent 24%),
linear-gradient(180deg, #15243b, transparent);
}
.station-sign {
position: absolute;
top: 16%;
left: 10%;
padding: 14px 24px;
color: rgb(229 220 204 / 65%);
font-family: "Noto Serif SC", serif;
font-size: 22px;
letter-spacing: 0.35em;
background: rgb(26 31 40 / 70%);
border: 1px solid rgb(255 255 255 / 12%);
transform: rotate(-1deg);
}
.character {
position: absolute;
right: 16%;
bottom: 22%;
width: 280px;
height: 510px;
filter: drop-shadow(0 34px 36px rgb(0 0 0 / 40%));
}
.character-hair,
.character-face,
.character-coat {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.character-hair {
top: 20px;
width: 204px;
height: 250px;
background: #28242d;
border-radius: 52% 48% 44% 48% / 36% 40% 60% 64%;
box-shadow:
-42px 80px 0 -25px #28242d,
48px 90px 0 -28px #211f27;
}
.character-face {
top: 62px;
z-index: 2;
width: 142px;
height: 176px;
background: linear-gradient(145deg, #f4d5c4, #dfb39f);
border-radius: 48% 48% 46% 46% / 40% 42% 58% 60%;
}
.eye,
.mouth {
position: absolute;
display: block;
background: #4c3838;
}
.eye {
top: 82px;
width: 18px;
height: 9px;
border-radius: 50%;
}
.eye--left {
left: 31px;
}
.eye--right {
right: 31px;
}
.mouth {
bottom: 35px;
left: 50%;
width: 24px;
height: 7px;
border-radius: 0 0 50% 50%;
transform: translateX(-50%);
}
.character-coat {
bottom: 0;
z-index: 1;
width: 260px;
height: 320px;
background: linear-gradient(115deg, #c9baa6, #817a72);
border-radius: 46% 46% 7% 7% / 20% 20% 5% 5%;
clip-path: polygon(20% 0, 80% 0, 100% 100%, 0 100%);
}
.chapter-chip {
position: absolute;
top: 7%;
left: 50%;
padding: 6px 14px;
color: rgb(245 239 231 / 62%);
font-family: "Noto Serif SC", serif;
font-size: 12px;
letter-spacing: 0.16em;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 999px;
transform: translateX(-50%);
}
.dialogue-panel {
position: absolute;
right: 7%;
bottom: 116px;
left: 7%;
z-index: 10;
min-height: 126px;
padding: 20px 26px;
background: linear-gradient(100deg, rgb(13 17 27 / 94%), rgb(24 26 34 / 84%));
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 14px;
box-shadow: 0 22px 56px rgb(0 0 0 / 38%);
backdrop-filter: blur(18px);
}
.speaker-row {
display: flex;
align-items: baseline;
gap: 16px;
}
.speaker-row strong {
color: #f0c4b2;
font-family: "Noto Serif SC", serif;
font-size: 19px;
}
.speaker-row span {
color: rgb(241 237 231 / 40%);
font-size: 12px;
}
.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-size: 17px;
line-height: 1.75;
}
.suggestions {
position: absolute;
right: 8%;
bottom: 255px;
z-index: 12;
display: flex;
gap: 10px;
}
.suggestions button {
padding: 8px 13px;
color: rgb(245 241 234 / 72%);
background: rgb(15 19 29 / 64%);
backdrop-filter: blur(12px);
}
.composer {
position: absolute;
right: 7%;
bottom: 42px;
left: 7%;
z-index: 12;
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
}
.composer textarea {
min-height: 58px;
padding: 13px 16px;
resize: none;
color: rgb(250 247 242 / 92%);
outline: none;
background: rgb(12 15 24 / 86%);
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 12px;
}
.composer button {
min-width: 72px;
cursor: pointer;
border-radius: 12px;
}
.continue-button {
color: rgb(245 241 234 / 75%);
background: rgb(24 28 39 / 88%);
border: 1px solid rgb(255 255 255 / 13%);
}
.send-button {
color: #201918;
background: #e7b8a4;
border: 0;
}
.send-button:disabled {
cursor: default;
opacity: 0.42;
}
.statusline {
position: absolute;
right: 8%;
bottom: 15px;
left: 8%;
z-index: 12;
display: flex;
justify-content: space-between;
color: rgb(242 238 231 / 28%);
font-size: 10px;
letter-spacing: 0.06em;
}
.state-screen {
display: grid;
width: 100%;
height: calc(100% - 58px);
place-content: center;
gap: 8px;
color: rgb(245 241 234 / 70%);
}
.state-screen--error {
color: #f1b5ad;
}