Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
508 lines (469 loc) · 18.1 KB
/
Copy pathapp.js
File metadata and controls
508 lines (469 loc) · 18.1 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
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
import {
PROJECTS,
createInitialState,
getChat,
getCurrentProject,
getFeature,
getVersion,
transition,
} from "./state.mjs";
const app = document.querySelector("#app");
const toast = document.querySelector("#toast");
let state = createInitialState();
function esc(value = "") {
const element = document.createElement("span");
element.textContent = String(value);
return element.innerHTML;
}
function icon(name) {
const icons = {
timeline:
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M4 17h16M9 7v6m6 4v-6"/><circle cx="9" cy="14" r="2"/><circle cx="15" cy="9" r="2"/></svg>',
map:
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="m8.4 11 7.2-4M8.4 13l7.2 4"/></svg>',
arrow:
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h13m-5-5 5 5-5 5"/></svg>',
close:
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg>',
archive:
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v13H4zM3 4h18v4H3zm6 8h6"/></svg>',
};
return icons[name] ?? "";
}
function renderProjectRail(project) {
const projectButtons = PROJECTS.map(
(item) => `
<button
class="project-switch ${item.id === project.id ? "is-current" : ""}"
type="button"
data-action="select-project"
data-project-id="${item.id}"
aria-pressed="${item.id === project.id}"
>
<span class="project-monogram">${esc(item.monogram)}</span>
<span>
<strong>${esc(item.name)}</strong>
<small>${item.chats.length ? `${item.chats.length} 个已归档 Chat` : "尚未归档"}</small>
</span>
<i aria-hidden="true"></i>
</button>
`,
).join("");
return `
<aside class="project-rail" aria-label="项目列表">
<a class="wordmark" href="#" aria-label="意图账本首页">
<span class="wordmark-mark">意</span>
<span><strong>意图账本</strong><small>Intent ledger</small></span>
</a>
<div class="rail-label"><span>项目</span><b>${PROJECTS.length}</b></div>
<nav class="project-list">${projectButtons}</nav>
<button class="new-project" type="button" data-action="demo-only">
<span>+</span> 添加项目
</button>
<div class="rail-foot">
<span class="local-dot"></span>
<span><strong>只保存在本机</strong><small>最后整理 · ${esc(project.updatedAt)}</small></span>
</div>
</aside>
`;
}
function renderHeader(project) {
return `
<header class="project-header">
<div class="project-identity">
<span class="eyebrow">当前项目</span>
<div>
<h1>${esc(project.name)}</h1>
<span class="project-path">${esc(project.rootPath)}</span>
</div>
</div>
<nav class="view-tabs" aria-label="项目视图">
<button
type="button"
class="${state.view === "timeline" ? "is-active" : ""}"
data-action="set-view"
data-view="timeline"
aria-current="${state.view === "timeline" ? "page" : "false"}"
>
${icon("timeline")}<span>开发时间线</span>
</button>
<button
type="button"
class="${state.view === "map" ? "is-active" : ""}"
data-action="set-view"
data-view="map"
aria-current="${state.view === "map" ? "page" : "false"}"
>
${icon("map")}<span>产品地图</span>
</button>
</nav>
<button class="archive-button" type="button" data-action="demo-only">
${icon("archive")}<span>归档本次开发</span>
</button>
</header>
`;
}
function renderEmptyTimeline() {
return `
<section class="empty-view">
<span class="empty-wire" aria-hidden="true"></span>
<span class="empty-card">还没有挂上档案</span>
<h2>这个项目还没有归档过开发记录</h2>
<p>完成一次值得保留的工作后,它会以 Chat 和版本的形式出现在这里。</p>
<button type="button" data-action="demo-only">了解如何归档</button>
</section>
`;
}
function renderChatNode(chat, index) {
const notes = chat.versions
.map(
(version, versionIndex) => `
<button
type="button"
class="version-note ${state.selectedVersionId === version.id ? "is-selected" : ""}"
data-action="open-version"
data-chat-id="${chat.id}"
data-version-id="${version.id}"
style="--note-tilt:${versionIndex % 2 ? "1.2deg" : "-1deg"}"
>
<span class="note-pin" aria-hidden="true"></span>
<span class="version-number">${esc(version.number)}</span>
<strong>${esc(version.title)}</strong>
<small>${esc(version.date)}</small>
</button>
`,
)
.join("");
return `
<article
class="chat-node ${state.selectedChatId === chat.id ? "is-selected" : ""}"
style="--card-tilt:${index % 2 ? "0.7deg" : "-0.6deg"}"
>
<span class="wire-clasp" aria-hidden="true"></span>
<div class="chat-card">
<div class="chat-meta">
<span class="agent-badge">${esc(chat.agentMark)}</span>
<span><strong>${esc(chat.agent)}</strong><small>${esc(chat.dateRange)}</small></span>
<b>${chat.versions.length} 个版本</b>
</div>
<h3>${esc(chat.title)}</h3>
<p>${esc(chat.summary)}</p>
<div class="surface-tags">
${chat.surfaceNames.map((name) => `<span>${esc(name)}</span>`).join("")}
</div>
</div>
<div class="version-stack">${notes}</div>
</article>
`;
}
function renderTimeline(project) {
if (!project.chats.length) return renderEmptyTimeline();
return `
<section class="timeline-view" aria-labelledby="timeline-title">
<div class="view-intro">
<div>
<span class="eyebrow">项目形成过程</span>
<h2 id="timeline-title">沿着开发,找回每一次为什么</h2>
<p>${esc(project.description)}</p>
</div>
<div class="timeline-legend">
<span><i class="legend-chat"></i>${project.chats.length} 个 Chat</span>
<span><i class="legend-version"></i>${project.chats.reduce((sum, chat) => sum + chat.versions.length, 0)} 次归档</span>
</div>
</div>
<div class="timeline-scroll" tabindex="0" aria-label="横向开发时间线">
<div class="timeline-stage" style="--chat-count:${project.chats.length}">
<span class="timeline-wire wire-shadow" aria-hidden="true"></span>
<span class="timeline-wire" aria-hidden="true"></span>
<span class="wire-start" aria-hidden="true">从这里开始</span>
${project.chats.map(renderChatNode).join("")}
<div class="timeline-now" aria-label="当前时间">
<span></span>
<strong>现在</strong>
<small>继续书写</small>
</div>
</div>
</div>
<p class="scroll-hint"><span>←</span> 横向浏览项目记忆 <span>→</span></p>
</section>
`;
}
function renderSurface(project, surface, index) {
const features = surface.featureIds
.map((featureId) => getFeature(project, featureId))
.filter(Boolean);
return `
<article class="surface-card" style="--surface-tilt:${index % 2 ? "0.5deg" : "-0.35deg"}">
<div class="surface-head">
<span class="surface-index">0${index + 1}</span>
<div><span class="eyebrow">${esc(surface.type)} · ${esc(surface.route)}</span><h3>${esc(surface.name)}</h3></div>
</div>
<p>${esc(surface.description)}</p>
<div class="feature-list">
${features
.map(
(feature) => `
<button type="button" data-action="open-feature" data-feature-id="${feature.id}">
<span><strong>${esc(feature.name)}</strong><small>${esc(feature.currentBehavior)}</small></span>
${icon("arrow")}
</button>
`,
)
.join("")}
</div>
<footer><span>最近产物</span><strong>${esc(surface.artifact)}</strong></footer>
</article>
`;
}
function renderProductMap(project) {
const capabilities = project.capabilityIds
.map((id) => getFeature(project, id))
.filter(Boolean);
return `
<section class="product-map-view" aria-labelledby="map-title">
<div class="view-intro">
<div>
<span class="eyebrow">当前产品结构</span>
<h2 id="map-title">先找到页面,再选择要改的功能</h2>
<p>功能是定向修改的最小单位;历史变化随时可以追溯回开发时间线。</p>
</div>
<div class="map-summary">
<strong>${project.surfaces.length}</strong><span>页面与流程</span>
<i></i>
<strong>${project.features.length}</strong><span>产品功能</span>
</div>
</div>
<div class="map-section-head">
<div><span class="section-mark">A</span><div><strong>页面与流程</strong><small>回答“我在哪里”</small></div></div>
<span>${project.surfaces.length} 个入口</span>
</div>
<div class="surface-grid">
${project.surfaces.map((surface, index) => renderSurface(project, surface, index)).join("")}
</div>
${
capabilities.length
? `
<div class="capability-lane">
<div class="capability-title">
<span class="section-mark">B</span>
<div><strong>公共能力</strong><small>跨页面持续工作</small></div>
</div>
<span class="capability-line" aria-hidden="true"></span>
<div class="capability-list">
${capabilities
.map(
(feature) => `
<button type="button" data-action="open-feature" data-feature-id="${feature.id}">
<span class="capability-dot"></span>
<span><strong>${esc(feature.name)}</strong><small>${feature.surfaceNames.length} 个关联页面</small></span>
${icon("arrow")}
</button>
`,
)
.join("")}
</div>
</div>
`
: ""
}
</section>
`;
}
function renderVersionDrawer(project) {
const chat = getChat(project, state.selectedChatId);
const version = getVersion(project, state.selectedChatId, state.selectedVersionId);
if (!chat || !version) return "";
const featureButtons = version.featureIds
.map((featureId) => getFeature(project, featureId))
.filter(Boolean)
.map(
(feature) => `
<button type="button" class="linked-feature" data-action="open-feature" data-feature-id="${feature.id}">
<span><small>${esc(feature.eyebrow)}</small><strong>${esc(feature.name)}</strong></span>
${icon("arrow")}
</button>
`,
)
.join("");
return `
<aside class="detail-drawer version-drawer" aria-labelledby="drawer-title">
<div class="drawer-top">
<span class="drawer-type">归档版本</span>
<button type="button" class="drawer-close" data-action="close-panel" aria-label="关闭详情">${icon("close")}</button>
</div>
<div class="version-title">
<span>${esc(version.number)}</span>
<div><h2 id="drawer-title">${esc(version.title)}</h2><p>${esc(chat.agent)} · ${esc(version.date)}</p></div>
</div>
<p class="drawer-lead">${esc(version.summary)}</p>
<section class="drawer-section">
<h3><span>01</span> 关键决策</h3>
<div class="decision-list">
${version.decisions
.map(
(decision) => `
<article><strong>${esc(decision.title)}</strong><p>${esc(decision.reason)}</p></article>
`,
)
.join("")}
</div>
</section>
<section class="drawer-section">
<h3><span>02</span> 涉及功能</h3>
<div class="linked-features">${featureButtons}</div>
</section>
<section class="drawer-section evidence-grid">
<div><h3><span>03</span> 验证</h3>${version.evidence.map((item) => `<p>✓ ${esc(item)}</p>`).join("")}</div>
<div><h3><span>04</span> 产物</h3>${version.artifacts.map((item) => `<p>↗ ${esc(item)}</p>`).join("")}</div>
</section>
${
version.openIssues.length
? `<div class="open-issue"><span>留给下一次</span><p>${version.openIssues.map(esc).join(";")}</p></div>`
: ""
}
<footer class="drawer-source"><span>来源 Chat</span><strong>${esc(chat.title)}</strong><small>${esc(chat.dateRange)}</small></footer>
</aside>
`;
}
function renderFeatureDrawer(project) {
const feature = getFeature(project, state.selectedFeatureId);
if (!feature) return "";
return `
<aside class="detail-drawer feature-drawer" aria-labelledby="drawer-title">
<div class="drawer-top">
<span class="drawer-type">${feature.isCapability ? "公共能力" : "产品功能"}</span>
<button type="button" class="drawer-close" data-action="close-panel" aria-label="关闭详情">${icon("close")}</button>
</div>
<span class="feature-eyebrow">${esc(feature.eyebrow)}</span>
<h2 id="drawer-title">${esc(feature.name)}</h2>
<p class="drawer-lead">${esc(feature.description)}</p>
<div class="behavior-card">
<span>现在如何工作</span>
<p>${esc(feature.currentBehavior)}</p>
<div>${feature.surfaceNames.map((name) => `<b>${esc(name)}</b>`).join("")}</div>
</div>
<section class="drawer-section">
<h3><span>01</span> 为什么这样做</h3>
<ul class="plain-list">${feature.decisions.map((item) => `<li>${esc(item)}</li>`).join("") || "<li>尚未记录关键决策</li>"}</ul>
</section>
<section class="drawer-section">
<h3><span>02</span> 代码位置</h3>
<div class="code-map">
${feature.codeAreas
.map(
(area) => `
<div><span>${esc(area.label)}</span>${area.items.map((item) => `<code>${esc(item)}</code>`).join("")}</div>
`,
)
.join("") || "<p>尚未建立代码映射</p>"}
</div>
</section>
<section class="drawer-section">
<h3><span>03</span> 变化历史</h3>
<div class="revision-list">
${
feature.revisions
.map(
(revision) => `
<button
type="button"
data-action="open-revision"
data-chat-id="${revision.chatId}"
data-version-id="${revision.versionId}"
>
<span class="revision-agent">${revision.agent === "Codex" ? "X" : "C"}</span>
<span><strong>${esc(revision.agent)} · ${esc(revision.version)}</strong><small>${esc(revision.summary)}</small></span>
<time>${esc(revision.date)}</time>
</button>
`,
)
.join("") || "<p>这个功能还没有归档变化。</p>"
}
</div>
</section>
<button type="button" class="modify-button" data-action="demo-only">
<span>修改这个功能</span>${icon("arrow")}
</button>
<p class="modify-note">会带上当前行为、历史决策和准确代码位置。</p>
</aside>
`;
}
function renderDrawer(project) {
if (state.panel === "version") return renderVersionDrawer(project);
if (state.panel === "feature") return renderFeatureDrawer(project);
return "";
}
function renderApp() {
const project = getCurrentProject(state);
const drawer = renderDrawer(project);
app.innerHTML = `
${renderProjectRail(project)}
<div class="project-shell">
${renderHeader(project)}
<main class="main-canvas ${drawer ? "has-drawer" : ""}">
${state.view === "timeline" ? renderTimeline(project) : renderProductMap(project)}
</main>
</div>
<div class="drawer-backdrop ${drawer ? "is-visible" : ""}" data-action="close-panel" aria-hidden="true"></div>
${drawer}
`;
bindActions();
focusSelectedElement();
}
function dispatch(event) {
state = transition(state, event);
renderApp();
}
function bindActions() {
app.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => {
const action = button.dataset.action;
if (action === "set-view") dispatch({ type: "SET_VIEW", view: button.dataset.view });
if (action === "select-project") {
dispatch({ type: "SELECT_PROJECT", projectId: button.dataset.projectId });
}
if (action === "open-version") {
dispatch({
type: "OPEN_VERSION",
chatId: button.dataset.chatId,
versionId: button.dataset.versionId,
});
}
if (action === "open-feature") {
dispatch({ type: "OPEN_FEATURE", featureId: button.dataset.featureId });
}
if (action === "open-revision") {
dispatch({
type: "OPEN_REVISION",
chatId: button.dataset.chatId,
versionId: button.dataset.versionId,
});
}
if (action === "close-panel") dispatch({ type: "CLOSE_PANEL" });
if (action === "demo-only") showToast("这一入口将在下一轮接入");
});
});
}
function focusSelectedElement() {
if (state.panel) {
requestAnimationFrame(() => app.querySelector(".drawer-close")?.focus({ preventScroll: true }));
}
if (state.view === "timeline" && state.selectedChatId) {
requestAnimationFrame(() => {
app.querySelector(".chat-node.is-selected")?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
inline: "center",
block: "nearest",
});
});
}
}
function showToast(message) {
toast.textContent = message;
toast.classList.add("is-visible");
window.setTimeout(() => toast.classList.remove("is-visible"), 2200);
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.panel) dispatch({ type: "CLOSE_PANEL" });
});
renderApp();