shell.js
16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
/* ─────────────────────────────────────────────────────────────
LemonBoss · NewFunction PRD — Shared Shell JS v2
3-level sidebar (Section → Subgroup → Page) + 全局搜索
───────────────────────────────────────────────────────────── */
(function(){
// ── Site map (3-level: section → subgroup → page) ──
const SITE = [
{ id:'overview', label:'OVERVIEW · 总览', groups:[
{ label:'Introduction', pages:[
{ f:'index.html', t:'封面 · Hero', s:'三层 Loop 一图速览' },
{ f:'reading-guide.html', t:'阅读指南', s:'人 + Claude Code 两套读法' },
]},
]},
{ id:'prd', label:'PRD · 产品需求', groups:[
{ label:'Foundation', pages:[
{ f:'prd-background.html', t:'背景 / 问题陈述', s:'为什么要做这个' },
{ f:'prd-personas.html', t:'角色 · User Stories', s:'三类用户 + 故事卡' },
{ f:'requirement.html', t:'原始需求 Verbatim', s:'人类原话 + 解析' },
]},
{ label:'Specification', pages:[
{ f:'scope.html', t:'范围与原则', s:'in / out / 原则' },
{ f:'prd-feature-tree.html',t:'功能树', s:'4 级树状结构' },
{ f:'prd-user-flows.html', t:'用户流程图', s:'3 个核心 user flow' },
{ f:'prd-success-metrics.html', t:'成功指标 · KPI', s:'P0 验收 + 长期指标' },
]},
{ label:'Reference', pages:[
{ f:'prd-glossary.html', t:'术语表', s:'30 个核心词' },
]},
]},
{ id:'design', label:'DESIGN · 系统设计', groups:[
{ label:'Loops', pages:[
{ f:'loops-overview.html', t:'三层 Loop 总览', s:'L1 ⊂ L2 ⊂ L3' },
{ f:'loops-l1.html', t:'L1 · Unit Loop', s:'代码 · 单测 · 重试' },
{ f:'loops-l2.html', t:'L2 · Integration', s:'集成 · 归因 · 回写' },
{ f:'loops-l3.html', t:'L3 · Human', s:'新需求 · 增量重跑' },
]},
{ label:'Agents', pages:[
{ f:'agents-overview.html', t:'六个 Agent 总览', s:'职责矩阵 · 调用关系' },
{ f:'agents-pm.html', t:'PM Agent', s:'需求 → PRD' },
{ f:'agents-architect.html',t:'Architect Agent', s:'PRD → SDD + units.json' },
{ f:'agents-coder.html', t:'Coder Agent', s:'写前后端代码' },
{ f:'agents-tester.html', t:'Tester Agent', s:'写 / 跑 Unit Test' },
{ f:'agents-integrator.html',t:'Integrator Agent', s:'跑集成测试' },
{ f:'agents-triager.html', t:'BugTriager Agent', s:'集成失败归因' },
]},
]},
{ id:'sdd', label:'SDD · MVCP 设计', groups:[
{ label:'Overview', pages:[
{ f:'sdd-overview.html', t:'MVCP 总览', s:'四象限 + 机械化抽取' },
]},
{ label:'MVCP', pages:[
{ f:'sdd-model.html', t:'M · Model', s:'15 条数据结构' },
{ f:'sdd-view.html', t:'V · View', s:'11 个组件签名' },
{ f:'sdd-control.html', t:'C · Control', s:'17 个 API / 服务方法' },
{ f:'sdd-process.html', t:'P · Process', s:'9 个长进程 / 状态机' },
]},
]},
{ id:'proto', label:'PROTOTYPE · 原型', groups:[
{ label:'Pages', pages:[
{ f:'ui-dashboard.html', t:'主看板 · Dashboard', s:'4 个核心区域' },
{ f:'proto-feedback-modal.html', t:'反馈模态框', s:'+ NEW REQUIREMENT' },
{ f:'proto-unit-drawer.html', t:'Unit 详情抽屉', s:'最近 3 次 run' },
{ f:'proto-prd-diff.html', t:'PRD diff 面板', s:'section 级差异' },
]},
{ label:'System', pages:[
{ f:'ui-design-system.html',t:'视觉设计语言', s:'色板 · 字号 · 排版' },
]},
]},
{ id:'arch', label:'ARCHITECTURE · 架构', groups:[
{ label:'Mapping', pages:[
{ f:'arch-mapping.html', t:'对 LemonBoss 的映射', s:'REUSE / NEW / PATCH' },
{ f:'arch-components.html', t:'三个新组件', s:'LoopController · DocStore · UnitRegistry' },
]},
{ label:'Runtime', pages:[
{ f:'arch-workflow.html', t:'运行时工作流', s:'端到端时序图' },
{ f:'data-layout.html', t:'文件空间布局', s:'data/companies/.../projects/' },
{ f:'data-documents.html', t:'四类文档关系', s:'PRD / SDD / units / runs' },
]},
{ label:'Implementation', pages:[
{ f:'arch-project-root.html', t:'Project Root', s:'根目录概念 · 三种模式 · 创建入口' },
{ f:'arch-tool-registry.html', t:'Tool Registry', s:'79 个工具的 JS 实现层' },
{ f:'arch-acp-bridge.html', t:'ACP Bridge', s:'LoopController ↔ agent-manager' },
{ f:'arch-git-versioning.html',t:'Git Versioning', s:'每项目 git repo + 自动 commit' },
]},
]},
{ id:'api', label:'API · 接口规范', groups:[
{ label:'Specs', pages:[
{ f:'api.html', t:'REST 端点 + 骨架', s:'/api/devloop/*' },
{ f:'api-sse.html', t:'SSE 事件总线', s:'15 类事件完整规格' },
{ f:'api-contracts.html', t:'Unit Contract 规范', s:'契约 JSON schema' },
]},
]},
{ id:'ops', label:'OPS · 运行视图', groups:[
{ label:'Runtime Views', pages:[
{ f:'ops-progress.html', t:'Progress', s:'项目实时进度' },
{ f:'ops-loop-log.html', t:'Loop Log', s:'L1/L2/L3 时间线' },
{ f:'ops-buglist.html', t:'Buglist', s:'跨 run bug 台账' },
{ f:'ops-changelog.html', t:'Changelog', s:'版本变更流' },
]},
]},
{ id:'plan', label:'PLAN · 实施与复现', groups:[
{ label:'Implementation', pages:[
{ f:'plan-migration.html', t:'最小改造路径', s:'P0 → P4 五阶段' },
]},
{ label:'Operations', pages:[
{ f:'plan-cost-concurrency.html', t:'成本 & 并发预算', s:'Token / $ / 墙钟 / 并发上限' },
{ f:'plan-code-lifecycle.html', t:'代码归宿', s:'生成代码留沙箱 / export / PR' },
]},
{ label:'Quality & Verification', pages:[
{ f:'plan-risks.html', t:'风险与待决', s:'7 个风险点' },
{ f:'plan-reproducibility.html', t:'Claude Code 复现指南', s:'一个 prompt 重建项目' },
]},
]},
];
// Flat list for prev/next + search resolution
const FLAT = SITE.flatMap(sec => sec.groups.flatMap(g =>
g.pages.map(p => ({ ...p, section:sec.id, sectionLabel:sec.label, group:g.label }))
));
const currentPage = (document.body.dataset.page || 'index.html').trim();
// ── Build sidebar (3-level) ──
const sidebar = document.getElementById('sidebar');
if (sidebar){
sidebar.classList.add('sidebar');
sidebar.innerHTML = `
<a class="brand" href="index.html">
<span class="dot"></span>
<span>
<b>LemonBoss · PRD</b>
<small>NewFunction · v0.3</small>
</span>
</a>
<div class="sb-search">
<div style="position:relative">
<input type="text" id="sbSearchInput" placeholder="搜索 (Ctrl/Cmd + K)" autocomplete="off" />
<button class="clear-btn" id="sbClearBtn" title="清除">×</button>
</div>
<div class="hint">在所有 ${FLAT.length} 页内搜索</div>
</div>
${SITE.map((section, sIx) => {
const sectionActive = section.groups.some(g => g.pages.some(p => p.f === currentPage));
return `
<div class="section-group" data-section="${section.id}" data-open="${sectionActive ? 'true':'false'}">
<div class="group-head">
<span><span class="ix">${String(sIx+1).padStart(2,'0')}</span>${section.label}</span>
<span class="chev">›</span>
</div>
<ul class="flat">${section.groups.map(g => {
const groupActive = g.pages.some(p => p.f === currentPage);
return `
<li><div class="subgroup" data-open="${groupActive ? 'true':'false'}">
<div class="sub-head">
<span>${g.label}</span>
<span class="chev2">›</span>
</div>
<ul>${g.pages.map(p => {
const active = p.f === currentPage;
return `<li><a href="${p.f}"${active?' class="active"':''}>
<span class="lbl">${p.t}</span>
${p.s ? `<span class="sub">${p.s}</span>` : ''}
</a></li>`;
}).join('')}</ul>
</div></li>`;
}).join('')}</ul>
</div>`;
}).join('')}
<div class="sb-footer">PRD-001 v0.3 · 2026-05-13<br/>Claude Opus 4.7</div>
`;
// Level 1 (Section) toggle
sidebar.querySelectorAll('.section-group > .group-head').forEach(head => {
head.addEventListener('click', () => {
const g = head.parentElement;
g.dataset.open = g.dataset.open === 'true' ? 'false' : 'true';
});
});
// Level 2 (Subgroup) toggle
sidebar.querySelectorAll('.subgroup > .sub-head').forEach(head => {
head.addEventListener('click', () => {
const g = head.parentElement;
g.dataset.open = g.dataset.open === 'true' ? 'false' : 'true';
});
});
}
// ── Prev / Next ──
const idx = FLAT.findIndex(p => p.f === currentPage);
const prev = idx > 0 ? FLAT[idx-1] : null;
const next = idx >= 0 && idx < FLAT.length-1 ? FLAT[idx+1] : null;
const navEl = document.querySelector('nav.prev-next');
if (navEl){
navEl.innerHTML =
(prev ? `<a class="prev" href="${prev.f}"><small>← Previous</small><b>${prev.t}</b></a>` : `<span class="prev"></span>`) +
(next ? `<a class="next" href="${next.f}"><small>Next →</small><b>${next.t}</b></a>` : `<span class="next"></span>`);
}
// ── Tabs (page-level content) ──
document.querySelectorAll('[data-tabs]').forEach(root => {
const buttons = root.querySelectorAll('.tabbar button');
const panels = root.querySelectorAll('.tabpanel');
buttons.forEach((btn, i) => btn.addEventListener('click', () => {
buttons.forEach(b => b.setAttribute('aria-selected','false'));
panels.forEach(p => p.hidden = true);
btn.setAttribute('aria-selected','true');
panels[i].hidden = false;
}));
});
// ── Search ──
const shell = document.querySelector('.shell');
const main = document.querySelector('main.page');
const input = document.getElementById('sbSearchInput');
const clearB = document.getElementById('sbClearBtn');
const searchBox = document.querySelector('.sb-search');
let panel = null;
let searchIndex = null;
let indexLoading = null;
function ensurePanel(){
if (panel) return panel;
panel = document.createElement('aside');
panel.className = 'search-panel';
panel.innerHTML = `
<div class="sp-head">
<span><span class="title">Search Results</span><span class="count" id="spCount"></span></span>
<button class="close-btn" id="spClose" title="关闭 (Esc)">×</button>
</div>
<div id="spList"></div>
`;
sidebar.parentElement.insertBefore(panel, main);
panel.querySelector('#spClose').addEventListener('click', closeSearch);
return panel;
}
async function loadIndex(){
if (searchIndex) return searchIndex;
if (indexLoading) return indexLoading;
indexLoading = fetch('assets/search-index.json')
.then(r => r.ok ? r.json() : { entries: [] })
.then(j => { searchIndex = j.entries || []; return searchIndex; })
.catch(() => { searchIndex = []; return searchIndex; });
return indexLoading;
}
function highlight(text, query){
if (!query) return text;
const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(re, '<mark>$1</mark>');
}
function snippet(content, query, len=180){
if (!content) return '';
const lc = content.toLowerCase();
const ix = lc.indexOf(query.toLowerCase());
if (ix < 0) return content.slice(0, len) + (content.length>len?'…':'');
const start = Math.max(0, ix - 40);
const end = Math.min(content.length, ix + query.length + 140);
return (start>0?'…':'') + content.slice(start, end) + (end<content.length?'…':'');
}
function pageMeta(f){
return FLAT.find(p => p.f === f);
}
async function doSearch(q){
q = q.trim();
if (!q){ closeSearch(); return; }
shell.classList.add('search-open');
ensurePanel();
const idx2 = await loadIndex();
const ql = q.toLowerCase();
const results = [];
for (const e of idx2){
let hits = 0;
const fields = [e.title, e.content, ...(e.headings||[])];
for (const f of fields) if (f && f.toLowerCase().includes(ql)) hits++;
if (hits){
results.push({ ...e, hits, snippet: snippet(e.content, q) });
}
}
results.sort((a,b) => b.hits - a.hits);
const panelEl = ensurePanel();
panelEl.querySelector('#spCount').textContent = results.length + ' 条';
const listEl = panelEl.querySelector('#spList');
if (results.length === 0){
listEl.innerHTML = `<div class="sp-empty">没找到 "<b>${q}</b>"<br/>试试别的关键词,或检查拼写</div>`;
return;
}
listEl.innerHTML = results.slice(0, 50).map(r => {
const meta = pageMeta(r.f) || { sectionLabel:'', group:'' };
const url = encodeURI(r.f) + '?q=' + encodeURIComponent(q);
return `
<div class="sp-result" data-href="${url}">
<div class="crumb">${meta.sectionLabel} ${meta.group ? '· '+meta.group : ''} <span class="count">${r.hits} hit${r.hits>1?'s':''}</span></div>
<a class="title" href="${url}">${highlight(r.title, q)}</a>
<div class="snippet">${highlight(r.snippet, q)}</div>
</div>
`;
}).join('');
listEl.querySelectorAll('.sp-result').forEach(el => {
el.addEventListener('click', e => {
if (e.target.tagName === 'A') return;
window.location.href = el.dataset.href;
});
});
}
function closeSearch(){
shell.classList.remove('search-open');
if (input) { input.value=''; searchBox.classList.remove('has-text'); }
}
if (input){
let timer = null;
input.addEventListener('input', e => {
const v = e.target.value;
searchBox.classList.toggle('has-text', !!v.trim());
clearTimeout(timer);
timer = setTimeout(() => doSearch(v), 180);
});
clearB.addEventListener('click', closeSearch);
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k'){
e.preventDefault(); input.focus(); input.select();
} else if (e.key === 'Escape' && shell.classList.contains('search-open')){
closeSearch();
}
});
}
// ── On-page highlight if arrived from search (?q=...) ──
const params = new URLSearchParams(location.search);
const q = params.get('q');
if (q){
const article = document.querySelector('article.body') || document.body;
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
const re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
let firstHit = null;
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const n of nodes){
if (n.parentElement && /SCRIPT|STYLE|MARK/i.test(n.parentElement.tagName)) continue;
if (re.test(n.textContent)){
const span = document.createElement('span');
span.innerHTML = n.textContent.replace(new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')+')','gi'), '<mark style="background:#FFE9B3;color:var(--ink);padding:0 2px;border-radius:2px;font-weight:600">$1</mark>');
n.parentNode.replaceChild(span, n);
if (!firstHit) firstHit = span;
}
}
if (firstHit){
setTimeout(() => firstHit.scrollIntoView({ behavior:'smooth', block:'center' }), 100);
}
}
})();