完善创作节点循环与等待态交互,并大幅打磨 Web 壳与会话运行时。

- 节点循环:配方开局直坐 DAG 第一步;验收后先问下一步意向,再确认开干并可钉编排参数;「按意见修改」只重跑当前节点。
- 询问分流:能力 opening 走说话面引导,可跳过追问按题干去重;追问与产物同轮挂载,避免拆成两段历史。
- 运行时:补强 revision 重跑、创作流程合并/进度指针、工具循环与 worker 执行;新增运行日志与 web:watch。
- 前端:统一等待态文案与底栏主按钮,完善询问卡/产物验收/呈现壳样式与交互。
- 同步世界模拟器模块提示、编排文档与相关测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 01:28:12 +08:00
parent 20938b04d7
commit ec474cb946
56 changed files with 7460 additions and 1372 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -93,3 +93,64 @@ export function formatWorkerDisplayTitle(workerId, action = null) {
if (action === "running") return `${base} · 执行中`;
return base;
}
/**
* `review_artifact` 底栏 / 工作面文案。流程编排核对工作流计划,不是「验收通过」。
* 须与 src/server/display-labels.ts `reviewComposerCopy` 保持一致。
*/
export function reviewComposerCopy(workerId, opts = {}) {
const hasQuestions = Boolean(opts.hasQuestions);
const id = String(workerId ?? "").trim();
if (id === "design-flow") {
return {
chip: "编排",
kicker: "核对编排",
taskLabel: "编排",
taskTitle: "核对工作流计划",
hint: hasQuestions
? "同意就按这个排;要改就写意见,在现有计划上改。下方追问可选答。"
: "同意就按这个排;要改就写意见,在现有计划上改。",
submitLabel: "按意见改编排",
acceptLabel: "确认编排",
placeholder: "改编排意见…",
emptyEnterHint: "空 Enter确认编排",
tone: "plan",
kind: "flow",
};
}
if (id === "opening-generator") {
return {
chip: "开场",
kicker: "待选定",
taskLabel: "开场",
taskTitle: "选定开场白",
hint: hasQuestions
? "同意就选定此开场;要改就写意见。下方追问可选答。"
: "同意就选定此开场;要改就写意见。",
submitLabel: "按意见修改",
acceptLabel: "选定此开场",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter选定此开场",
tone: "plan",
kind: "opening",
};
}
return {
chip: "验收",
kicker: "待验收",
taskLabel: "验收",
taskTitle: "验收产物",
hint: hasQuestions
? "同意就接受;要改就写意见,在现有产物上改。下方追问可选答。"
: "同意就接受;要改就写意见,在现有产物上改。",
submitLabel: "按意见修改",
acceptLabel: "接受",
placeholder: "修改意见…",
emptyEnterHint: "空 Enter接受",
tone: "accept",
kind: "accept",
};
}

View File

@@ -35,7 +35,7 @@
<div class="rail-panels">
<div class="rail-tab-panel active" id="rail-panel-books" role="tabpanel" data-rail-panel="books">
<nav class="path-bar" id="book-path-bar" aria-label="作品路径"></nav>
<nav class="path-bar" id="book-path-bar" aria-label="面包屑" hidden></nav>
<div class="book-list" id="book-list"></div>
<div id="book-box-overlay" class="book-box-overlay" hidden aria-hidden="true"></div>
</div>
@@ -60,14 +60,19 @@
</div>
</div>
<div class="main-head-right">
<a class="link" href="/modules.html">技能</a>
<a class="link" href="/shells.html">呈现壳</a>
<a class="link" href="/stats.html">Token</a>
<a class="link" href="/settings.html">设置</a>
<button type="button" class="btn-sm" id="btn-save-instance" hidden>保存定稿</button>
<button type="button" class="btn-sm" id="btn-save-play" hidden>保存游玩</button>
<button type="button" class="btn-sm" id="btn-export" disabled>导出</button>
<span class="status" id="status-pill"><span class="status-dot" id="status-dot"></span><span id="status-text"></span></span>
<details class="more-menu">
<summary class="btn-sm more-menu-sum" title="更多页面">更多</summary>
<div class="more-menu-panel" role="menu">
<a class="more-menu-item" href="/modules.html" role="menuitem">技能</a>
<a class="more-menu-item" href="/shells.html" role="menuitem">呈现壳</a>
<a class="more-menu-item" href="/stats.html" role="menuitem">Token</a>
<a class="more-menu-item" href="/settings.html" role="menuitem">设置</a>
</div>
</details>
</div>
</header>
@@ -77,8 +82,28 @@
<div id="message-feed" class="message-feed">
<p class="empty">点击左侧 + 新建作品</p>
</div>
<div id="workspace-stage" class="workspace-stage" hidden aria-label="验收工作面"></div>
<div id="questions-card-host" class="questions-card-host" hidden></div>
</section>
<aside id="coord-rail" class="coord-rail" hidden aria-label="此前对话">
<button
type="button"
class="coord-rail-toggle"
id="btn-coord-toggle"
aria-expanded="false"
title="展开 / 收起此前对话"
>
<span class="coord-rail-toggle-label">对话</span>
<span id="coord-drawer-count" class="coord-drawer-count"></span>
</button>
<div class="coord-rail-panel">
<header class="coord-rail-head">
<span class="coord-rail-title">此前对话</span>
<button type="button" class="btn-sm coord-rail-collapse" id="btn-coord-collapse" title="收起" aria-label="收起此前对话"></button>
</header>
<div id="coord-drawer-body" class="coord-drawer-body"></div>
</div>
</aside>
</div>
<footer class="composer" id="composer"></footer>
@@ -119,12 +144,13 @@
<button type="button" class="context-menu-item" id="msg-menu-context" data-msg-menu-action="view-context" hidden>查看上下文</button>
<button type="button" class="context-menu-item" id="msg-menu-edit" data-msg-menu-action="edit">编辑</button>
<button type="button" class="context-menu-item" id="msg-menu-copy" data-msg-menu-action="copy">复制</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">删除</button>
<button type="button" class="context-menu-item context-menu-separator-before" id="msg-menu-rollback" data-msg-menu-action="rollback" hidden>回退到这里</button>
<button type="button" class="context-menu-item context-menu-danger" id="msg-menu-delete" data-msg-menu-action="delete">从这里重开</button>
</menu>
<dialog id="dialog-context-trace" class="dialog dialog-wide">
<dialog id="dialog-context-trace" class="dialog dialog-wide" aria-labelledby="context-trace-title">
<form method="dialog" class="context-trace-form">
<h2>请求上下文</h2>
<h2 id="context-trace-title">上下文调用日志</h2>
<p class="dialog-desc" id="context-trace-meta"></p>
<pre id="context-trace-body" class="context-trace-body"></pre>
<div class="dialog-actions">

View File

@@ -2,10 +2,15 @@
* 结构化询问卡:点字母选中,点文案编辑;左右分页。
* 询问是对同一次发送的可选增强:有选中则问+答拼接;未选中则不带问。
* 点发送即答复 → 收起本轮卡(卡上无独立发送钮)。
* 产物验收挂载题:底栏有字优先按「改产物」发送;无字时才提交所选答案
* 产物验收挂载题:选项 + 底栏意见一并随「按意见修改」写回产物;空内容点该钮不等于接受
* 分页 / 跳过为悬浮控件,不占独立 header/footer 行。
*
* 能力「默认问题」(id=module-opening) 不是追问:对齐美学纲领开局,走说话面 + openingGuide。
*/
/** 与 phase-runtime maybeAskModuleOpening 写入的 id 对齐 */
export const MODULE_OPENING_QUESTION_ID = "module-opening";
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
@@ -14,6 +19,22 @@ function esc(s) {
.replace(/"/g, "&quot;");
}
/** 当前等待是否为能力默认问题(应呈现为开场引导,而非答题卡) */
export function isModuleOpeningWaiting(view) {
const wr = view?.waitingReason;
if (wr?.kind !== "worker_questions" || !Array.isArray(wr.questions)) return false;
const qs = normalizeQuestions(wr.questions);
return qs.length === 1 && qs[0].id === MODULE_OPENING_QUESTION_ID;
}
/** 取出默认问题正文(供 openingGuide / 说话面展示) */
export function getModuleOpeningPrompt(view) {
if (!isModuleOpeningWaiting(view)) return null;
const qs = normalizeQuestions(view.waitingReason.questions);
const prompt = qs[0]?.prompt?.trim();
return prompt || null;
}
function normalizeQuestions(raw) {
if (!Array.isArray(raw)) return [];
return raw
@@ -64,6 +85,8 @@ export function getActiveQuestions(view) {
// 默认每页 1 题,左右切换;勿一次堆多题
const pageSize = Math.max(1, Number(wr.pageSize) || 1);
if (wr.kind === "worker_questions" && wr.questions?.length) {
// 能力默认问题:不进答题卡(与美学纲领 openingGuide 同形)
if (isModuleOpeningWaiting(view)) return null;
return {
questions: normalizeQuestions(wr.questions),
workerId: wr.workerId,
@@ -114,6 +137,18 @@ export function getActiveQuestions(view) {
return null;
}
/**
* 本轮题是否已答复收起(卡不会再画出来)。
* 此时若仍把主区排成答题面,用户只会看到一片空壳。
*/
export function isQuestionCardDismissed(view, host) {
const el = host ?? document.getElementById("questions-card-host");
const active = getActiveQuestions(view);
if (!el?._qDismissed || !active?.questions?.length) return false;
const key = JSON.stringify(active.questions.map((q) => q.id));
return el._qDismissed.sessionId === view?.id && el._qDismissed.key === key;
}
function persistDraftsFromDom(host, state) {
host.querySelectorAll("[data-draft]").forEach((el) => {
const key = el.getAttribute("data-draft");
@@ -224,7 +259,9 @@ export function clearQuestionCardState(host, view) {
host._qState = null;
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open");
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
}
/**
@@ -238,7 +275,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
if (!active?.questions?.length) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open");
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
host._qDismissed = null;
return false;
}
@@ -251,7 +290,9 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
) {
host.hidden = true;
host.innerHTML = "";
host.classList.remove("is-open");
host.classList.remove("is-open", "is-expanded", "has-pager", "has-skip");
host.removeAttribute("role");
host.removeAttribute("aria-label");
return false;
}
@@ -264,11 +305,14 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
sessionId: view.id,
key: qKey,
page: 0,
expanded: false,
// questionId -> { optionId?, text, otherText? }
answers: {},
drafts: {}, // option key -> edited label
};
if (typeof state.expanded !== "boolean") state.expanded = false;
host._qState = state;
host.classList.toggle("is-expanded", state.expanded);
const { questions, pageSize, assessment, optional, skipLabel, dismissOnAccept } =
active;
@@ -303,20 +347,24 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
? `<button type="button" class="qcard-skip" data-qskip title="Esc">${esc(skipLabel || "跳过")}</button>`
: "";
const chromeClass = [
"qcard",
const chromeMods = [
pageCount > 1 ? "has-pager" : "",
optional ? "has-skip" : "",
]
.filter(Boolean)
.join(" ");
].filter(Boolean);
host.classList.remove("has-pager", "has-skip");
for (const c of chromeMods) host.classList.add(c);
host.setAttribute("role", "dialog");
host.setAttribute("aria-label", "询问");
host.innerHTML = `
<div class="${chromeClass}" role="dialog" aria-label="询问">
<div class="qcard-float">
${pagerHtml}
${skipHtml}
</div>
<button type="button" class="qcard-top" data-qtop title="${state.expanded ? "点击收起" : "点击展开"}" aria-label="${state.expanded ? "收起询问卡" : "展开询问卡"}">
<span class="qcard-top-grip" aria-hidden="true"></span>
</button>
<div class="qcard-body">
${dismissNote}
${assessmentHtml}
@@ -364,8 +412,43 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
</section>`;
})
.join("")}
</div>
</div>`;
</div>`;
const setExpanded = (next) => {
state.expanded = next;
host.classList.toggle("is-expanded", next);
const top = host.querySelector("[data-qtop]");
if (top) {
top.title = next ? "点击收起" : "点击展开";
top.setAttribute("aria-label", next ? "收起询问卡" : "展开询问卡");
}
};
host.querySelector("[data-qtop]")?.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
setExpanded(!state.expanded);
});
host.querySelector(".qcard-body")?.addEventListener("click", (e) => {
const t = e.target;
if (!(t instanceof Element)) return;
if (
t.closest(
".qcard-letter, .qcard-other-input, [contenteditable=true], .qcard-nav, .qcard-skip, .qcard-pager",
)
) {
return;
}
// 展开后点题干 / 评估区收起;收起时点内容区展开
if (state.expanded) {
if (t.closest(".qcard-prompt, .qcard-assessment, .qcard-optional-note")) {
setExpanded(false);
}
return;
}
setExpanded(true);
});
host.querySelector("[data-qskip]")?.addEventListener("click", () => {
_handlers.onSkipQuestions?.();
@@ -401,17 +484,37 @@ export function renderQuestionsCard(host, view, _handlers = {}) {
text = (labelEl?.textContent ?? state.drafts[draftKey] ?? "").trim();
state.answers[qid] = { optionId: oid, text };
}
renderQuestionsCard(host, view, _handlers);
// 只切换选中态,避免整卡 innerHTML 重绘把 .qcard-body 滚回顶部
section.querySelectorAll(".qcard-opt").forEach((li) => {
li.classList.toggle("is-selected", li.getAttribute("data-oid") === oid);
});
if (!state.expanded) setExpanded(true);
_handlers.onAnswersChange?.();
});
});
host.querySelectorAll("[data-other]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault();
persistDraftsFromDom(host, state);
el.blur();
_handlers.onCardEnter?.();
});
el.addEventListener("input", () => _handlers.onAnswersChange?.());
});
host.querySelectorAll("[data-draft]").forEach((el) => {
el.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
el.blur();
}
if (e.key !== "Enter" || e.shiftKey || e.isComposing) return;
e.preventDefault();
persistDraftsFromDom(host, state);
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";
el.blur();
_handlers.onCardEnter?.();
});
el.addEventListener("input", () => _handlers.onAnswersChange?.());
el.addEventListener("blur", () => {
const key = el.getAttribute("data-draft");
if (key) state.drafts[key] = el.textContent ?? "";

View File

@@ -1,62 +1,124 @@
/* 呈现壳预览页
* 注意:本页也引入 styles.csspresent 样式),会给 body 设 overflow:hidden
* 必须在此覆盖,否则侧栏后几项点不到。
*/
/* 呈现壳预览页 — 主工作面 = 壳画布;侧栏只负责换壳 */
html,
body.shells-page {
height: auto;
min-height: 100%;
overflow: auto;
background: var(--st-bg, #f4f4f5);
}
.shells-page .st-shell.shells-shell {
max-width: 1180px;
max-width: 1280px;
align-items: start;
grid-template-columns: 200px minmax(0, 1fr);
}
.shells-page .st-rail {
--st-rail-w: 220px;
width: var(--st-rail-w);
width: 200px;
position: sticky;
top: var(--st-top-h, 48px);
max-height: calc(100dvh - var(--st-top-h, 48px));
overflow-y: auto;
min-height: 0;
align-self: start;
gap: 2px;
}
.shells-tone-label {
margin-top: 1rem !important;
}
.shells-density {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
margin: 0.5rem 0 0.35rem;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent-soft, #e8f0fe) 70%, transparent);
font-size: 13px;
line-height: 1.45;
}
.shells-density span {
.st-rail-hint {
margin: 0 8px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--muted, #666);
text-transform: uppercase;
line-height: 1.45;
color: var(--st-faint, #a1a1aa);
}
.shells-list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
flex: 0 0 auto;
}
.shells-list .st-rail-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 8px 10px;
line-height: 1.25;
}
.shells-list .shell-item-name {
font-size: 13px;
font-weight: 600;
color: inherit;
}
.shells-list .shell-item-task {
font-size: 11px;
font-weight: 400;
color: var(--st-faint, #a1a1aa);
}
.shells-list .st-rail-item.active .shell-item-task {
color: color-mix(in srgb, var(--st-accent, #2563eb) 65%, #71717a);
}
.shells-main {
min-height: calc(100dvh - var(--st-top-h, 48px));
display: flex;
flex-direction: column;
}
.shells-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px 20px;
padding: 14px 24px 12px;
border-bottom: 1px solid var(--st-border, #e4e4e7);
background: var(--st-surface, #fff);
position: sticky;
top: var(--st-top-h, 48px);
z-index: 3;
}
.shells-toolbar-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.shells-toolbar-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--st-faint, #a1a1aa);
}
.shells-toolbar-meta {
margin-left: auto;
align-items: flex-end;
text-align: right;
max-width: 280px;
}
.shells-toolbar-meta .shells-meta-title {
margin: 0;
font-size: 14px;
font-weight: 650;
color: var(--st-text, #18181b);
}
.shells-toolbar-meta .shells-meta-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--st-muted, #71717a);
line-height: 1.4;
}
.shells-tones {
display: flex;
flex-wrap: wrap;
@@ -66,65 +128,102 @@ body.shells-page {
.shells-tone-chip {
font: inherit;
font-size: 12px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border, #ddd);
background: var(--surface, #fff);
padding: 5px 10px;
border-radius: 6px;
border: 1px solid var(--st-border, #e4e4e7);
background: var(--st-surface, #fff);
cursor: pointer;
color: inherit;
color: var(--st-muted, #71717a);
}
.shells-tone-chip:hover {
background: #f4f4f5;
color: var(--st-text, #18181b);
}
.shells-tone-chip:focus-visible {
outline: 2px solid var(--st-accent, #2563eb);
outline-offset: 2px;
}
.shells-tone-chip.active {
border-color: transparent;
background: var(--accent-soft, #e8f0fe);
background: var(--st-accent-soft, #dbeafe);
color: var(--st-accent, #2563eb);
font-weight: 600;
}
.shells-body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.shells-meta p {
margin: 0 0 0.35rem;
line-height: 1.5;
}
.shells-preview-label,
.shells-tweaks h2,
.shells-help h2 {
margin: 0 0 0.5rem;
font-size: 13px;
font-weight: 700;
color: var(--muted, #666);
/* 主工作面:尽量大的阅读画布 */
.shells-preview-wrap {
flex: 1 1 auto;
padding: 20px 24px 28px;
background: #ebebed;
}
.shells-preview {
min-height: 280px;
padding: 16px;
border-radius: 12px;
border: 1px dashed var(--border, #ccc);
background:
radial-gradient(ellipse at 20% 0%, rgba(15, 118, 110, 0.06), transparent 50%),
var(--bg, #f6f6f4);
min-height: min(70vh, 720px);
padding: 20px 16px 28px;
border-radius: 10px;
border: 1px solid var(--st-border, #e4e4e7);
background: #f4f4f5;
overflow: visible;
}
.shells-preview .present-shell {
max-width: 640px;
max-width: 42rem;
margin: 0 auto;
box-shadow: 0 10px 28px rgba(28, 25, 23, 0.07);
box-shadow: none;
}
.shells-preview[data-shell="turn_panel"] .present-shell,
.shells-preview[data-shell="split_board"] .present-shell,
.shells-preview[data-shell="chapter_reader"] .present-shell {
max-width: 780px;
max-width: 52rem;
}
/* 预览区不裁切长正文 */
.shells-preview {
overflow: visible;
.shells-preview .present-shell-badge {
display: none;
}
.shells-details {
margin: 0 24px 28px;
border: 1px solid var(--st-border, #e4e4e7);
border-radius: 8px;
background: var(--st-surface, #fff);
}
.shells-details > summary {
cursor: pointer;
padding: 12px 14px;
font-size: 13px;
font-weight: 600;
color: var(--st-muted, #71717a);
list-style: none;
}
.shells-details > summary::-webkit-details-marker {
display: none;
}
.shells-details > summary::before {
content: "▸ ";
color: var(--st-faint, #a1a1aa);
}
.shells-details[open] > summary::before {
content: "▾ ";
}
.shells-details[open] > summary {
border-bottom: 1px solid var(--st-border, #e4e4e7);
color: var(--st-text, #18181b);
}
.shells-details-body {
padding: 14px 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.shells-tweak-grid {
@@ -144,30 +243,68 @@ body.shells-page {
font: inherit;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border, #ddd);
border: 1px solid var(--st-border, #e4e4e7);
max-width: 320px;
}
.shells-help ul {
.shells-help-list {
margin: 0;
padding-left: 1.2rem;
padding-left: 1.15rem;
line-height: 1.55;
font-size: 13px;
}
.shells-help .muted {
font-size: 12px;
color: var(--muted, #888);
color: var(--st-muted, #71717a);
}
@media (max-width: 800px) {
.shells-page .st-shell.shells-shell {
grid-template-columns: 1fr;
}
.shells-page .st-rail {
position: static;
max-height: none;
width: auto;
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--st-border, #e4e4e7);
}
.shells-list {
flex-direction: row;
flex-wrap: wrap;
}
.shells-list .st-rail-item {
width: auto;
max-width: 46%;
}
.st-rail-hint,
.st-rail-note {
display: none;
}
.shells-toolbar {
position: static;
}
.shells-toolbar-meta {
margin-left: 0;
text-align: left;
align-items: flex-start;
max-width: none;
width: 100%;
}
.shells-preview-wrap {
padding: 12px;
}
.shells-preview .present-shell {
max-width: 100%;
}
}
.shells-details {
margin: 0 12px 20px;
}
}

View File

@@ -28,44 +28,42 @@
<div class="st-shell shells-shell">
<aside class="st-rail" aria-label="壳列表">
<p class="st-rail-label">呈现</p>
<p class="st-rail-label"></p>
<p class="st-rail-hint">按「要同时看见什么」选,不是按好看选。</p>
<nav class="shells-list" id="shell-list" role="tablist"></nav>
<p class="st-rail-label shells-tone-label">装饰语气</p>
<div class="shells-tones" id="tone-list" role="group" aria-label="tone_chrome"></div>
<p class="st-rail-label shells-tone-label">正文长度</p>
<div class="shells-tones" id="length-list" role="group" aria-label="body length"></div>
<p class="st-rail-note">
正文按 600~3000 字设计:随字数撑高、页面滚动;侧栏/选择坞钉住。创作只改内容绑定
主任务永远是读正文(常 600~3000 字)。侧栏只换壳;长度/语气在预览顶栏
</p>
</aside>
<main class="st-main">
<header class="st-main-head">
<div>
<h1 id="panel-title">呈现壳预览</h1>
<p class="st-sub" id="panel-subtitle">用示例数据看布局与可调项</p>
<main class="st-main shells-main">
<div class="shells-toolbar" role="toolbar" aria-label="预览条件">
<div class="shells-toolbar-block">
<span class="shells-toolbar-label" id="length-label">正文长度</span>
<div class="shells-tones" id="length-list" role="group" aria-labelledby="length-label"></div>
</div>
</header>
<div class="st-body shells-body">
<section class="shells-meta" id="shell-meta"></section>
<section class="shells-preview-wrap">
<h2 class="shells-preview-label">预览</h2>
<div id="shell-preview" class="shells-preview"></div>
</section>
<section class="shells-tweaks">
<h2>本页可调(对应创作「微调轴」)</h2>
<div class="shells-tweak-grid" id="tweak-controls"></div>
</section>
<section class="shells-help">
<h2>设计意图</h2>
<ul>
<li>正文默认装得下单轮 <strong>600~3000 字</strong>:区域随内容长高,由页面/消息区滚动,不塞进固定小框。</li>
<li>双栏壳:侧栏 sticky选择坞选项区 sticky 贴底——长文读完控件还在。</li>
<li>壳差在「谁占主柱」;可改的是字段/显示名/行动开关/tone不是重画布局。</li>
</ul>
<p class="muted">说明文档:<code>docs/play-presentation-shells.md</code></p>
</section>
<div class="shells-toolbar-block">
<span class="shells-toolbar-label" id="tone-label">语气</span>
<div class="shells-tones" id="tone-list" role="group" aria-labelledby="tone-label"></div>
</div>
<div class="shells-toolbar-block shells-toolbar-meta" id="shell-meta"></div>
</div>
<section class="shells-preview-wrap" aria-label="壳预览主工作面">
<div id="shell-preview" class="shells-preview"></div>
</section>
<details class="shells-details">
<summary>微调轴与说明</summary>
<div class="shells-details-body">
<div class="shells-tweak-grid" id="tweak-controls"></div>
<ul class="shells-help-list">
<li>主工作面 = 上面的壳。读长文时正文撑高,页面滚动;侧栏 / 选择坞钉住。</li>
<li>壳差在「必须同时看见什么」:只正文 / 正文+状态 / 正文+线索 / 正文+选项…</li>
<li>创作只改字段绑定与显示名,不发明新布局。文档:<code>docs/play-presentation-shells.md</code></li>
</ul>
</div>
</details>
</main>
</div>

View File

@@ -1,71 +1,64 @@
/**
* 呈现壳预览页:切换壳 / 装饰语气 / 正文长度,用示例数据渲染 present-shells
* 正文设计目标:单轮常见 600~3000 字 —— 随字数撑高,外层滚动;侧栏/选项坞钉住
* 呈现壳预览:主工作面 = 壳画布
* 按「要同时看见什么」选壳;正文按 600~3000 字撑高滚动
*/
import {
PRESENT_SHELL_IDS,
renderPresentShellHtml,
} from "./present-shells.js";
/** task = 同时可见关系选壳依据name = 短名 */
const SHELL_INFO = {
prose: {
name: "纯散文",
blurb: "整块都是正文,无 HUD。长文直接往下滚。",
density: "正文随字数伸展;无侧栏争面积",
scenes: "轻对话、纯叙事、不要状态栏",
task: "只要正文",
blurb: "无 HUD。读完即止。",
},
chat_monitor: {
name: "对话 + 监控",
blurb: "顶栏芯片固定矮;主聊/场面再长也只往下长。",
density: "监控一行;正文撑高滚动",
scenes: "网恋 / 日常扮演 / 轻 AIRP",
name: "对话+监控",
task: "正文 + 扫状态",
blurb: "顶栏芯片矮;主聊/场面往下长。",
},
spotlight: {
name: "场面主视",
blurb: "芯片一行 + 大正文柱。适合一幕写满。",
density: "正文主导并伸展;监控不占高",
scenes: "电影感 RP、一幕一景、少机制",
task: "大正文 + 弱状态",
blurb: "无聊天气泡框;场面即画布。",
},
turn_panel: {
name: "回合面板",
blurb: "场面叙述再长,右侧交互物钉在视口内对照。",
density: "正文撑高;侧栏 sticky;行动在下",
scenes: "资源、检定、场景交互、跑团感",
task: "场面 + 物 + 行动",
blurb: "对照交互物;行动在下",
},
split_board: {
name: "双栏看板",
blurb: "左栏长文滚动,右栏线索钉住——对照读。",
density: "左正文伸展;右侧栏 sticky",
scenes: "调查、推理、多线索并行",
task: "正文 ‖ 线索",
blurb: "左右对照;侧栏钉住。",
},
choice_dock: {
name: "选择坞",
blurb: "先读完长局面;选项坞 sticky 贴视口底,不用翻回顶。",
density: "正文撑高;底部坞钉住",
scenes: "选项驱动、AVG、分支关口",
task: "局面 + 选项",
blurb: "读完再选;坞贴视口底。",
},
chapter_reader: {
name: "章节阅读",
blurb: "章 + 长阅读柱;窄进度侧栏 sticky。",
density: "正文伸展为主;侧栏窄且钉住",
scenes: "长文 / 爽文 / 先纲后章",
task: "章正文 + 进度",
blurb: "阅读柱为主;进度窄栏钉住",
},
};
const TONES = [
{ id: "default", label: "默认" },
{ id: "messenger", label: "讯息" },
{ id: "book", label: "书页" },
{ id: "terminal", label: "终端" },
{ id: "messenger", label: "讯息" },
{ id: "book", label: "书页" },
{ id: "terminal", label: "终端" },
];
const LENGTHS = [
{ id: "short", label: "短~120" },
{ id: "mid", label: "中~800" },
{ id: "long", label: "长~2500" },
{ id: "short", label: "短" },
{ id: "mid", label: "中 ·800" },
{ id: "long", label: "长 ·2500" },
];
/** 中等长度 ≈800 字 */
const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话,只是把购物袋往怀里收紧了一点。水从伞骨边缘连成线,砸在柏油路上,溅起细碎的白点。
路口的灯跳成绿色。你们一起迈步——这一次,谁也没有先松开。对面便利店的玻璃上映着两个人并肩的轮廓,忽明忽暗,像谁随手画的草稿。
@@ -82,7 +75,6 @@ const BODY_MID = `雨还在下。你把伞往她那边偏了偏,她没说话
门开了一条缝。暖光漏出来,照在你们脚边的水渍上。她没有立刻进去,只是抬眼看你,像在等一句还没说出口的话。你把伞收拢,水仍顺着伞尖滴到地毯边缘。谁也没有提「进来坐坐」,但空气里已经有了那句话的形状。`;
/** 长文 ≈2500 字(重复段落拼成,预览用) */
const BODY_LONG = `${BODY_MID}
天桥下的风从另一头灌过来,把塑料袋吹得啪啪响。你想起上一次走过这里,还是夏天,蝉声吵得人没法好好说话;如今只剩雨,以及偶尔驶过的车灯,把积水切成一条条亮痕。
@@ -115,9 +107,7 @@ const BODY_LONG = `${BODY_MID}
「有的不用还。」她看着河对岸的塔吊,「有的还了,反而像没发生过。」
塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你忽然明白,呈现壳要解决的不是「字大一点」,而是这一长段话落在界面里时,读者还愿不愿意往下看——状态栏不能抢戏,选项不能在中途消失,侧栏得钉住好让人对照线索
你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层。
塔吊缓缓旋转,吊臂在灰蓝天空里划出钝角。你们在桥上站了一会儿。风把伞套吹得鼓起,又瘪下去。她把热饮塞进你手里:「暖的。你昨晚肯定没睡好。」你没有否认。杯壁的温度从掌心爬上手腕,像把雨夜重新烘了一遍,只留下能说出口的那一层
分开前,她抬手点了点伞:「这个,先放你那儿。等下一场雨。」
@@ -231,7 +221,7 @@ function buildTweaks() {
show_suggested_actions: state.showActions,
block_labels: { ...state.blockLabels },
empty_states: {
aside: "(侧栏空:可在创作里改空态文案",
aside: "(侧栏空)",
footer: "(文末空)",
},
};
@@ -252,27 +242,25 @@ function buildPacket() {
function render() {
const info = SHELL_INFO[state.shell] || {
name: state.shell,
task: "",
blurb: "",
scenes: "",
density: "",
};
const packet = buildPacket();
document.getElementById("panel-title").textContent = info.name;
document.getElementById("panel-subtitle").textContent = `${info.scenes} · 正文约 ${packet._chars} 字(不计空白)`;
document.getElementById("shell-meta").innerHTML = `
<p><strong>${esc(info.name)}</strong><code>${esc(state.shell)}</code></p>
<p>${esc(info.blurb)}</p>
<p class="shells-density"><span>长文策略</span>${esc(info.density)} · 当前试读 <strong>${packet._chars}</strong> 字</p>
<p class="muted">适用:${esc(info.scenes)}。请用上方「正文长度」切到长文,确认侧栏/选项是否仍可用。</p>
<p class="shells-meta-title">${esc(info.name)}</p>
<p class="shells-meta-sub">${esc(info.task)} · 试读 ${packet._chars} 字<br>${esc(info.blurb)}</p>
`;
const preview = document.getElementById("shell-preview");
preview.dataset.shell = state.shell;
preview.innerHTML = renderPresentShellHtml(packet, esc, { tweaks: buildTweaks() });
preview.innerHTML = renderPresentShellHtml(packet, esc, {
tweaks: buildTweaks(),
});
document.querySelectorAll("[data-shell]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-shell") === state.shell);
btn.setAttribute("aria-selected", btn.getAttribute("data-shell") === state.shell ? "true" : "false");
});
document.querySelectorAll("[data-tone]").forEach((btn) => {
btn.classList.toggle("active", btn.getAttribute("data-tone") === state.tone);
@@ -289,8 +277,9 @@ function mount() {
const list = document.getElementById("shell-list");
list.innerHTML = PRESENT_SHELL_IDS.map((id) => {
const info = SHELL_INFO[id];
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab">
${esc(info?.name || id)}
return `<button type="button" class="st-rail-item" data-shell="${esc(id)}" role="tab" aria-selected="false">
<span class="shell-item-name">${esc(info?.name || id)}</span>
<span class="shell-item-task">${esc(info?.task || "")}</span>
</button>`;
}).join("");
@@ -307,15 +296,15 @@ function mount() {
document.getElementById("tweak-controls").innerHTML = `
<label class="field">
<input type="checkbox" id="tweak-show-actions" />
显示建议行动」(对应微调轴 show_suggested_actions
显示建议行动show_suggested_actions
</label>
<label class="field">
正文显示名block_labels.body
<input type="text" id="tweak-label-body" placeholder="例如:短信记录 / 场面 / 本章" />
正文显示名block_labels.body
<input type="text" id="tweak-label-body" placeholder="短信记录 / 场面 / 本章" />
</label>
<label class="field">
监控显示名block_labels.monitor
<input type="text" id="tweak-label-monitor" placeholder="例如:状态 / 今日" />
监控显示名block_labels.monitor
<input type="text" id="tweak-label-monitor" placeholder="状态 / 今日" />
</label>
`;

File diff suppressed because it is too large Load Diff