dflow-sdd-ddd 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +824 -1
  2. package/CONTRIBUTING.md +16 -10
  3. package/README.en.md +156 -200
  4. package/README.md +89 -144
  5. package/TEMPLATE-COVERAGE.md +15 -8
  6. package/TEMPLATE-LANGUAGE-GLOSSARY.md +15 -1
  7. package/bin/dflow.js +36 -4
  8. package/docs/commands.en.md +110 -0
  9. package/docs/commands.md +101 -0
  10. package/docs/doctor-uncertainty.en.md +212 -0
  11. package/docs/doctor-uncertainty.md +212 -0
  12. package/docs/evaluating-dflow.en.md +29 -11
  13. package/docs/evaluating-dflow.md +8 -6
  14. package/docs/npm-publish-checklist.md +3 -1
  15. package/docs/release-versioning-policy.md +8 -2
  16. package/docs/upgrading.en.md +196 -0
  17. package/docs/upgrading.md +197 -0
  18. package/docs/using-with-claude-code.en.md +25 -10
  19. package/docs/using-with-claude-code.md +20 -7
  20. package/docs/using-with-codex.en.md +18 -6
  21. package/docs/using-with-codex.md +16 -5
  22. package/docs/using-with-github-copilot.en.md +25 -10
  23. package/docs/using-with-github-copilot.md +21 -8
  24. package/lib/doc-shapes.json +997 -0
  25. package/lib/doctor-checks.js +2654 -0
  26. package/lib/init.js +3583 -107
  27. package/lib/render-diagrams.js +1474 -0
  28. package/lib/render.js +865 -49
  29. package/package.json +2 -2
  30. package/templates/brownfield/references/drift-verification.md +4 -0
  31. package/templates/brownfield/references/finish-feature-flow.md +635 -88
  32. package/templates/brownfield/references/finish-feature-follow-up.md +60 -0
  33. package/templates/brownfield/references/finish-feature-minimal-host.md +406 -0
  34. package/templates/brownfield/references/finish-feature-post-hoc-hotfix.md +95 -0
  35. package/templates/brownfield/references/git-integration.md +160 -15
  36. package/templates/brownfield/references/init-project-flow.md +26 -4
  37. package/templates/brownfield/references/modify-existing-flow.md +412 -87
  38. package/templates/brownfield/references/modify-existing-follow-up.md +121 -0
  39. package/templates/brownfield/references/modify-existing-post-hoc-hotfix.md +82 -0
  40. package/templates/brownfield/references/new-feature-flow.md +61 -6
  41. package/templates/brownfield/references/new-phase-flow.md +57 -7
  42. package/templates/brownfield/references/pr-review-checklist.md +303 -10
  43. package/templates/brownfield/scaffolding/AI-AGENT-GUIDE.md +158 -34
  44. package/templates/brownfield/scaffolding/CLAUDE-md-snippet.md +1 -1
  45. package/templates/brownfield/scaffolding/Git-principles-gitflow.md +75 -6
  46. package/templates/brownfield/scaffolding/Git-principles-trunk.md +82 -8
  47. package/templates/brownfield/scaffolding/_conventions.md +50 -28
  48. package/templates/brownfield/scaffolding/_overview.md +1 -0
  49. package/templates/brownfield/templates/_index.md +151 -7
  50. package/templates/brownfield/templates/analysis.md +79 -0
  51. package/templates/brownfield/templates/behavior.md +1 -0
  52. package/templates/brownfield/templates/context-definition.md +1 -0
  53. package/templates/brownfield/templates/context-map.md +2 -1
  54. package/templates/brownfield/templates/glossary.md +1 -0
  55. package/templates/brownfield/templates/lightweight-spec.md +154 -11
  56. package/templates/brownfield/templates/models.md +1 -0
  57. package/templates/brownfield/templates/phase-spec.md +9 -1
  58. package/templates/brownfield/templates/rules.md +1 -0
  59. package/templates/brownfield/templates/tech-debt.md +1 -0
  60. package/templates/common/references/ddd-modeling-guide.md +33 -16
  61. package/templates/{greenfield → common}/references/dflow-feedback-flow.md +2 -1
  62. package/templates/common/references/flow-rationale-registry.md +130 -0
  63. package/templates/common/skill/SKILL.md +13 -11
  64. package/templates/greenfield/references/drift-verification.md +4 -0
  65. package/templates/greenfield/references/finish-feature-flow.md +625 -89
  66. package/templates/greenfield/references/finish-feature-follow-up.md +60 -0
  67. package/templates/greenfield/references/finish-feature-minimal-host.md +363 -0
  68. package/templates/greenfield/references/finish-feature-post-hoc-hotfix.md +95 -0
  69. package/templates/greenfield/references/git-integration.md +148 -15
  70. package/templates/greenfield/references/init-project-flow.md +28 -8
  71. package/templates/greenfield/references/modify-existing-flow.md +378 -85
  72. package/templates/greenfield/references/modify-existing-follow-up.md +103 -0
  73. package/templates/greenfield/references/modify-existing-post-hoc-hotfix.md +82 -0
  74. package/templates/greenfield/references/new-feature-flow.md +67 -4
  75. package/templates/greenfield/references/new-phase-flow.md +56 -7
  76. package/templates/greenfield/references/pr-review-checklist.md +287 -8
  77. package/templates/greenfield/scaffolding/AI-AGENT-GUIDE.md +153 -32
  78. package/templates/greenfield/scaffolding/CLAUDE-md-snippet.md +5 -2
  79. package/templates/greenfield/scaffolding/Git-principles-gitflow.md +74 -6
  80. package/templates/greenfield/scaffolding/Git-principles-trunk.md +88 -12
  81. package/templates/greenfield/scaffolding/_conventions.md +50 -28
  82. package/templates/greenfield/scaffolding/_overview.md +6 -2
  83. package/templates/greenfield/templates/_index.md +137 -7
  84. package/templates/greenfield/templates/aggregate-design.md +1 -0
  85. package/templates/greenfield/templates/analysis.md +79 -0
  86. package/templates/greenfield/templates/behavior.md +1 -0
  87. package/templates/greenfield/templates/context-definition.md +1 -0
  88. package/templates/greenfield/templates/context-map.md +2 -1
  89. package/templates/greenfield/templates/events.md +4 -1
  90. package/templates/greenfield/templates/glossary.md +1 -0
  91. package/templates/greenfield/templates/lightweight-spec.md +154 -11
  92. package/templates/greenfield/templates/models.md +1 -0
  93. package/templates/greenfield/templates/phase-spec.md +9 -1
  94. package/templates/greenfield/templates/rules.md +1 -0
  95. package/templates/greenfield/templates/tech-debt.md +1 -0
  96. package/templates/brownfield/references/dflow-feedback-flow.md +0 -251
package/lib/render.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // dflow render — specs Markdown -> human-readable static HTML (PROPOSAL-073).
2
2
  //
3
3
  // Markdown stays the AI-facing source of truth; this command projects it into
4
- // a mirrored .html tree plus a root index.html file-tree for human reading.
5
- // Every run is a full rebuild (the index tree and in-tree autolinks depend on
4
+ // a mirrored .html tree plus a root index.html for human reading.
5
+ // Every run is a full rebuild (the index and in-tree autolinks depend on
6
6
  // the whole file set, so partial rebuilds would leave stale links).
7
7
  //
8
8
  // Rendering parity baseline: the retired dev prototype
@@ -12,6 +12,37 @@
12
12
  // highlighting, .md link rewriting with GFM-style heading ids (CJK-safe), and
13
13
  // in-tree autolinking of inline-code .md mentions.
14
14
  //
15
+ // PROPOSAL-077 A1 extends that baseline with long-field readability: cards
16
+ // holding a long field span the full grid row, long fields get prose spacing,
17
+ // and wall-length fields are clamped behind a pure-CSS expand toggle (print
18
+ // always fully expands). Layout-only — cell content is never split or
19
+ // reworded, and the page stays JavaScript-free.
20
+ //
21
+ // PROPOSAL-079: features/completed/ (append-only archive) is not inlined in
22
+ // the root index — it renders as one physical index page per year (grouped
23
+ // by the SPEC-YYYYMMDD directory-name prefix; non-conforming entries in an
24
+ // 'other' bucket), with the root tree carrying year links only. Every
25
+ // generated index page is a reserved output in the projection-collision
26
+ // guard.
27
+ //
28
+ // PROPOSAL-100: in a file named analysis.md, each filled `### LC-nn` /
29
+ // `### FL-nn` subsection also gets its lifecycle or flow drawn as inline SVG
30
+ // above its unchanged cards, or a one-line note saying why not
31
+ // (lib/render-diagrams.js). Every other page, and every analysis.md whose
32
+ // LC/FL tables are still the template's (placeholder rows only), renders
33
+ // exactly as before.
34
+ //
35
+ // PROPOSAL-101: on a recognised specs root (shared/_conventions.md is
36
+ // present) the root index is a grouped directory instead of the file tree —
37
+ // Features, Domain, architecture and migration, shared, everything else —
38
+ // each group a native <details> closed on first open (a lone group opens),
39
+ // with a purpose line, a collapsed reading guide and a few path-scoped role
40
+ // labels; year pages gain one orientation line and list each feature
41
+ // directory as a row (the same rows active/ gets). Any other --src keeps the
42
+ // file tree. On both sides, index and year-page hrefs percent-encode `%`,
43
+ // `#` and `?`. Mirror pages are untouched, and the page stays
44
+ // JavaScript-free.
45
+ //
15
46
  // marked ships as ESM-only and `require(esm)` is not on by default until Node
16
47
  // 22.12 (engines allows >=22.0.0), so this CommonJS module MUST load it via
17
48
  // dynamic import('marked') — never via a require() call.
@@ -19,6 +50,8 @@
19
50
  const fs = require('node:fs/promises');
20
51
  const path = require('node:path');
21
52
 
53
+ const diagrams = require('./render-diagrams.js');
54
+
22
55
  const DEFAULT_SRC = 'dflow/specs';
23
56
  const DEFAULT_OUT = 'dflow-specs-html';
24
57
  const DEFAULT_TITLE = 'dflow specs';
@@ -53,7 +86,110 @@ const STATUS_PILL = {
53
86
  draft: 'neutral'
54
87
  };
55
88
 
56
- const GHERKIN_KEYWORDS = /^(\s*)(Scenario(?: Outline)?:|Background:|Examples:|Given|When|Then|And|But)(?=[ \t]|$)/gm;
89
+ // Gherkin keyword highlighting reaches three contexts: ```gherkin/feature
90
+ // fences, untagged fences that LOOK like scenario blocks, and plain prose
91
+ // paragraphs whose lines are all keyword-led (the dominant OBTS behavior.md
92
+ // style — steps as ordinary text with two-space line breaks). The prose and
93
+ // untagged-fence paths require >= 2 keyword-led lines: a lone English prose
94
+ // paragraph starting with "When the …" must never light up.
95
+ const GHERKIN_KW_SRC = '(Scenario(?: Outline)?:|Background:|Examples:|Given|When|Then|And|But)';
96
+ const GHERKIN_KEYWORDS = new RegExp(`^(\\s*)${GHERKIN_KW_SRC}(?=[ \\t]|$)`, 'gm');
97
+ const GHERKIN_PROSE_LINE = new RegExp(`^(\\s*)${GHERKIN_KW_SRC}(?=[ \\t])`);
98
+ // Step keywords carry per-keyword classes (Given=setup, When=action,
99
+ // Then=assertion, And/But=continuation); block headers share kw-s.
100
+ const GHERKIN_KW_CLASS = { Given: 'kw-g', When: 'kw-w', Then: 'kw-t', And: 'kw-a', But: 'kw-a' };
101
+
102
+ function gherkinKwSpan(kw) {
103
+ const cls = kw.endsWith(':') ? 'kw-s' : GHERKIN_KW_CLASS[kw];
104
+ return `<span class="kw ${cls}">${kw}</span>`;
105
+ }
106
+
107
+ function highlightGherkin(escapedText) {
108
+ return escapedText.replace(GHERKIN_KEYWORDS, (m, lead, kw) => `${lead}${gherkinKwSpan(kw)}`);
109
+ }
110
+
111
+ // >= 2 keyword-led lines -> treat an untagged fence as a scenario block.
112
+ function looksLikeGherkin(text) {
113
+ const hits = String(text).match(GHERKIN_KEYWORDS);
114
+ return hits !== null && hits.length >= 2;
115
+ }
116
+
117
+ // Raw inline HTML can carry a newline or a literal <br> inside an attribute
118
+ // value, and the string-level line split below would then inject a keyword
119
+ // span into the middle of a tag (review visual-r1 F1). Any html token in
120
+ // the paragraph's inline tree disqualifies it from prose highlighting —
121
+ // codespans, links, and em/strong stay eligible (their rendered tags can
122
+ // never begin a split line without also failing the keyword-led test).
123
+ function hasRawHtmlToken(tokens) {
124
+ for (const token of tokens || []) {
125
+ if (token.type === 'html' || (token.tokens && hasRawHtmlToken(token.tokens))) {
126
+ return true;
127
+ }
128
+ }
129
+ return false;
130
+ }
131
+
132
+ // Split rendered inline HTML into [line, delim, line, …] at hard-break
133
+ // <br> tags and soft-break newlines that sit OUTSIDE any tag. Markdown
134
+ // link/image titles may span lines, so a newline can legally live inside
135
+ // a rendered title="…" attribute — splitting there would let the keyword
136
+ // wrapper inject a span mid-tag (review visual-r2 F1). Marked-rendered
137
+ // attributes are entity-escaped, so tag extents are exactly the unescaped
138
+ // '<'…'>' pairs; raw html tokens (where that guarantee fails) are already
139
+ // excluded by hasRawHtmlToken before this runs.
140
+ function splitProseLines(innerHtml) {
141
+ const html = String(innerHtml);
142
+ const parts = [];
143
+ let cur = '';
144
+ let inTag = false;
145
+ for (let i = 0; i < html.length; i++) {
146
+ const ch = html[i];
147
+ if (inTag) {
148
+ cur += ch;
149
+ if (ch === '>') {
150
+ inTag = false;
151
+ }
152
+ continue;
153
+ }
154
+ if (ch === '<') {
155
+ const br = /^<br\s*\/?>/i.exec(html.slice(i));
156
+ if (br) {
157
+ parts.push(cur, br[0]);
158
+ cur = '';
159
+ i += br[0].length - 1;
160
+ } else {
161
+ inTag = true;
162
+ cur += ch;
163
+ }
164
+ continue;
165
+ }
166
+ if (ch === '\n') {
167
+ parts.push(cur, '\n');
168
+ cur = '';
169
+ continue;
170
+ }
171
+ cur += ch;
172
+ }
173
+ parts.push(cur);
174
+ return parts;
175
+ }
176
+
177
+ // Prose steps: a paragraph qualifies when it holds >= 2 non-empty lines
178
+ // (tag-aware split, see above) and EVERY one starts with a step/header
179
+ // keyword; then each line's keyword is wrapped — always at tag depth zero,
180
+ // because every segment starts outside a tag by construction. The
181
+ // every-line rule keeps hard-wrapped ordinary prose (where one wrapped
182
+ // line may start with "When …") from ever qualifying.
183
+ function highlightProseSteps(innerHtml) {
184
+ const parts = splitProseLines(innerHtml);
185
+ const lines = parts.filter((p, i) => i % 2 === 0 && p.trim() !== '');
186
+ if (lines.length < 2 || !lines.every((line) => GHERKIN_PROSE_LINE.test(line))) {
187
+ return innerHtml;
188
+ }
189
+ return parts
190
+ .map((p, i) => (i % 2 === 0 ? p.replace(GHERKIN_PROSE_LINE, (m, lead, kw) => `${lead}${gherkinKwSpan(kw)}`) : p))
191
+ .join('');
192
+ }
57
193
 
58
194
  // All multi-column tables render as cards (user decision 2026-07-09 after two
59
195
  // prototype demo rounds; no per-table heuristic). Degenerate single-column
@@ -64,6 +200,21 @@ const CAT_HEADERS = new Set([
64
200
  'status', 'subdomain type', 'layer', 'severity', 'owner / team', 'producer'
65
201
  ]);
66
202
 
203
+ // PROPOSAL-077 A1 long-field thresholds, in plain-text code points of the
204
+ // cell (tags stripped; CJK counts 1 per character). LONG promotes the card
205
+ // to a full grid row plus prose line spacing — at ~24 CJK chars per line in
206
+ // a 330px card, 200 chars is 8+ rendered lines, beyond any convention-
207
+ // compliant summary. CLAMP additionally caps the field at CLAMP_LINES with
208
+ // a CSS-only expand toggle; 400 chars still exceeds CLAMP_LINES at full
209
+ // content width (~59 CJK chars per line), so the toggle only appears where
210
+ // clipping is near-certain. Latin text runs narrower per char, so a wide
211
+ // latin field can show a toggle whose expansion reveals little — accepted
212
+ // noise; thresholds are not script-aware by design. The row title and chip
213
+ // columns are exempt: titles are names by design, chips are ≤40 chars.
214
+ const LONG_FIELD_CHARS = 200;
215
+ const CLAMP_FIELD_CHARS = 400;
216
+ const CLAMP_LINES = 6;
217
+
67
218
  const CSS = `
68
219
  :root {
69
220
  --bg:#f6f8f9; --surface:#ffffff; --frame:#eef2f4; --deep:#e7edef;
@@ -72,6 +223,7 @@ const CSS = `
72
223
  --ok-fg:#1e6b34; --ok-bg:#e2f2e6; --ok-line:#bfe0c7;
73
224
  --warn-fg:#8a5600; --warn-bg:#f7ecd7; --warn-line:#e8d5ac;
74
225
  --del-fg:#9c3838; --del-bg:#f9e9e9; --del-line:#e8c4c4;
226
+ --head-strong:#0a4f47; --kw-then:#6d4fc2;
75
227
  }
76
228
  @media (prefers-color-scheme: dark) {
77
229
  :root {
@@ -81,6 +233,7 @@ const CSS = `
81
233
  --ok-fg:#7bd397; --ok-bg:#142d1b; --ok-line:#235c33;
82
234
  --warn-fg:#e2b269; --warn-bg:#2f2510; --warn-line:#59461d;
83
235
  --del-fg:#e08f8f; --del-bg:#331a1a; --del-line:#5c2727;
236
+ --head-strong:#7fd8ca; --kw-then:#b6a0f0;
84
237
  }
85
238
  }
86
239
  * { box-sizing: border-box; }
@@ -102,13 +255,13 @@ pre {
102
255
  padding: 0.85rem 1.1rem; overflow-x: auto; font-size: 12.8px; line-height: 1.85;
103
256
  }
104
257
  pre code { background: none; border: none; padding: 0; font-size: inherit; }
105
- h1 { font-size: 25px; line-height: 1.35; margin: 0.2rem 0 1rem; }
258
+ h1 { font-size: 25px; line-height: 1.35; margin: 0.2rem 0 1rem; color: var(--head-strong); }
106
259
  h2 {
107
260
  font-size: 19px; margin: 2.2rem 0 0.6rem; padding-top: 1.2rem;
108
- border-top: 1px solid var(--line);
261
+ border-top: 1px solid var(--line); color: var(--head-strong);
109
262
  }
110
- h3 { font-size: 16.5px; margin: 1.6rem 0 0.4rem; }
111
- h4 { font-size: 15px; margin: 1.2rem 0 0.3rem; }
263
+ h3 { font-size: 16.5px; margin: 1.6rem 0 0.4rem; color: var(--accent); }
264
+ h4 { font-size: 15px; margin: 1.2rem 0 0.3rem; color: var(--accent); }
112
265
  blockquote {
113
266
  margin: 0.8rem 0; padding: 0.55rem 1rem;
114
267
  background: var(--accent-soft); border-left: 3px solid var(--accent);
@@ -149,7 +302,12 @@ tr:last-child td { border-bottom: none; }
149
302
  content: ""; position: absolute; left: 4px; top: 1px; width: 3px; height: 7px;
150
303
  border: solid var(--bg); border-width: 0 2px 2px 0; transform: rotate(45deg);
151
304
  }
152
- .kw { color: var(--accent); font-weight: 700; }
305
+ .kw { font-weight: 700; }
306
+ .kw-s { color: var(--accent); }
307
+ .kw-g { color: var(--ok-fg); }
308
+ .kw-w { color: var(--warn-fg); }
309
+ .kw-t { color: var(--kw-then); }
310
+ .kw-a { color: var(--soft); }
153
311
  .cards {
154
312
  display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
155
313
  gap: 0.9rem; align-items: start; margin: 0.8rem 0 1.4rem;
@@ -176,6 +334,30 @@ tr:last-child td { border-bottom: none; }
176
334
  margin-bottom: 0.05rem;
177
335
  }
178
336
  .fld-v { font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
337
+ .card.wide { grid-column: 1 / -1; }
338
+ .fld-v.prose { line-height: 1.85; position: relative; }
339
+ .fxt { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; pointer-events: none; }
340
+ .fxc { max-height: calc(${CLAMP_LINES} * 1.85em); overflow: hidden; position: relative; }
341
+ .fxc::after {
342
+ content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2.4em;
343
+ background: linear-gradient(to bottom, transparent, var(--surface));
344
+ }
345
+ .fxt:checked ~ .fxc { max-height: none; }
346
+ .fxt:checked ~ .fxc::after { display: none; }
347
+ .fxl {
348
+ display: inline-block; margin: 0.35rem 0 0.1rem; padding: 2px 10px;
349
+ font-size: 11.5px; font-weight: 600; color: var(--accent);
350
+ background: var(--accent-soft); border: 1px solid var(--accent-line);
351
+ border-radius: 999px; cursor: pointer; user-select: none;
352
+ }
353
+ .fxl .fxs { display: none; }
354
+ .fxt:checked ~ .fxl .fxm { display: none; }
355
+ .fxt:checked ~ .fxl .fxs { display: inline; }
356
+ .fxt:focus-visible ~ .fxl { outline: 2px solid var(--accent); outline-offset: 2px; }
357
+ @media print {
358
+ .fxc { max-height: none; }
359
+ .fxc::after, .fxl, .fxt { display: none; }
360
+ }
179
361
  .crumb { font-size: 12px; color: var(--faint); font-family: Consolas, monospace; margin: 0 0 1rem; }
180
362
  .crumb a { color: var(--faint); }
181
363
  .meta-card {
@@ -190,11 +372,39 @@ tr:last-child td { border-bottom: none; }
190
372
  margin-top: 3rem; padding-top: 0.9rem; border-top: 1px solid var(--line);
191
373
  color: var(--faint); font-size: 12px; font-family: Consolas, monospace;
192
374
  }
193
- ul.tree, ul.tree ul { list-style: none; padding-left: 1.1rem; line-height: 2; }
375
+ ul.tree, ul.tree ul { list-style: none; line-height: 2; font-size: 13.5px; }
194
376
  ul.tree { padding-left: 0; }
195
- ul.tree .dir { color: var(--faint); font-weight: 600; font-size: 13.5px; }
196
- ul.tree a { text-decoration: none; font-family: Consolas, monospace; font-size: 13.5px; }
377
+ ul.tree ul { padding-left: 1.35rem; margin: 0; }
378
+ ul.tree .dir { color: var(--faint); font-weight: 600; }
379
+ ul.tree .years { font-size: 13px; color: var(--faint); }
380
+ ul.tree .years a { font-weight: 600; }
381
+ .yearnav { font-size: 13.5px; color: var(--faint); margin: -0.3rem 0 1.1rem; font-family: Consolas, monospace; }
382
+ .yearnav a { text-decoration: none; font-weight: 600; }
383
+ .yearnav strong { color: var(--ink); }
384
+ ul.tree a { text-decoration: none; font-family: Consolas, monospace; }
197
385
  ul.tree a:hover { text-decoration: underline; }
386
+ ul.tree li { position: relative; }
387
+ ul.tree ul li::before {
388
+ content: ""; position: absolute; left: -0.85rem; top: 0; height: 100%;
389
+ border-left: 1px solid var(--line);
390
+ }
391
+ ul.tree ul li:last-child::before { height: 1em; }
392
+ ul.tree ul li::after {
393
+ content: ""; position: absolute; left: -0.85rem; top: 1em; width: 0.6rem;
394
+ border-top: 1px solid var(--line);
395
+ }
396
+ ul.tree .dir::before, ul.tree li > a::before {
397
+ content: ""; display: inline-block; width: 15px; height: 15px;
398
+ margin-right: 0.45em; vertical-align: -0.2em; background: currentColor;
399
+ }
400
+ ul.tree .dir::before {
401
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 4A1.5 1.5 0 0 1 3 2.5h3.1l1.6 2H13A1.5 1.5 0 0 1 14.5 6v6.5A1.5 1.5 0 0 1 13 14H3a1.5 1.5 0 0 1-1.5-1.5z'/%3E%3C/svg%3E") center/contain no-repeat;
402
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1.5 4A1.5 1.5 0 0 1 3 2.5h3.1l1.6 2H13A1.5 1.5 0 0 1 14.5 6v6.5A1.5 1.5 0 0 1 13 14H3a1.5 1.5 0 0 1-1.5-1.5z'/%3E%3C/svg%3E") center/contain no-repeat;
403
+ }
404
+ ul.tree li > a::before {
405
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4 1.5h5.4L13.5 5.6V13A1.5 1.5 0 0 1 12 14.5H4A1.5 1.5 0 0 1 2.5 13V3A1.5 1.5 0 0 1 4 1.5zm5.4 0v4.1h4.1z'/%3E%3C/svg%3E") center/contain no-repeat;
406
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4 1.5h5.4L13.5 5.6V13A1.5 1.5 0 0 1 12 14.5H4A1.5 1.5 0 0 1 2.5 13V3A1.5 1.5 0 0 1 4 1.5zm5.4 0v4.1h4.1z'/%3E%3C/svg%3E") center/contain no-repeat;
407
+ }
198
408
  `;
199
409
 
200
410
  // ------------------------------------------------------------------ file IO
@@ -342,12 +552,21 @@ function plainTableHtml(headerCells, rows) {
342
552
  // pill styling); remaining columns -> stacked label+value fields, with in-cell
343
553
  // <br> line breaks preserved verbatim. Empty cells are omitted. Degenerate
344
554
  // single-column tables keep the plain-table rendering.
345
- function cardsHtml(headerCells, rows) {
555
+ //
556
+ // Long fields (PROPOSAL-077 A1): a field at LONG_FIELD_CHARS+ makes its card
557
+ // span the full grid row and gets prose spacing; at CLAMP_FIELD_CHARS+ the
558
+ // field is additionally clamped behind a checkbox-driven CSS toggle (the
559
+ // checkbox stays focusable for keyboard use; print CSS removes the clamp).
560
+ // The field's HTML is emitted unmodified inside the clamp wrapper — never
561
+ // split, reflowed, or reworded. nextToggleId mints document-unique ids for
562
+ // the checkbox/label pairs.
563
+ function cardsHtml(headerCells, rows, nextToggleId) {
346
564
  const cards = rows.map((cells) => {
347
565
  const plains = cells.map(stripTags);
348
566
  const title = plains[0] ? cells[0] : '(未命名)';
349
567
  const chips = [];
350
568
  const fields = [];
569
+ let hasLongField = false;
351
570
  for (let i = 1; i < headerCells.length; i++) {
352
571
  const cell = cells[i] ?? '';
353
572
  const text = plains[i] ?? '';
@@ -363,22 +582,41 @@ function cardsHtml(headerCells, rows) {
363
582
  chips.push(`<span class="chip cat">${headerCells[i]}: ${cell}</span>`);
364
583
  }
365
584
  } else {
585
+ const chars = [...text].length;
586
+ let valueHtml = cell;
587
+ let valueClass = 'fld-v';
588
+ if (chars >= LONG_FIELD_CHARS) {
589
+ hasLongField = true;
590
+ valueClass = 'fld-v prose';
591
+ if (chars >= CLAMP_FIELD_CHARS) {
592
+ const id = nextToggleId();
593
+ valueHtml =
594
+ `<input type="checkbox" class="fxt" id="${id}">` +
595
+ `<div class="fxc">${cell}</div>` +
596
+ `<label class="fxl" for="${id}">` +
597
+ '<span class="fxm">展開全文 ▾</span><span class="fxs">收合 ▴</span></label>';
598
+ }
599
+ }
366
600
  fields.push(
367
601
  `<div class="fld"><span class="fld-k">${headerCells[i]}</span>` +
368
- `<div class="fld-v">${cell}</div></div>`
602
+ `<div class="${valueClass}">${valueHtml}</div></div>`
369
603
  );
370
604
  }
371
605
  }
372
606
  const chipsHtml = chips.length ? `<div class="card-chips">${chips.join('')}</div>` : '';
373
- return `<article class="card"><div class="card-title">${title}</div>` +
607
+ return `<article class="card${hasLongField ? ' wide' : ''}"><div class="card-title">${title}</div>` +
374
608
  `${chipsHtml}<div class="card-fields">${fields.join('')}</div></article>`;
375
609
  });
376
610
  return `<div class="cards">${cards.join('')}</div>\n`;
377
611
  }
378
612
 
379
613
  // Per-file renderer overrides. `seen` carries the per-document heading slug
380
- // dedup state, so a fresh renderer is built for every file.
614
+ // dedup state, so a fresh renderer is built for every file. toggleSeq mints
615
+ // the per-document ids for long-field clamp toggles; the `fldx-` prefix
616
+ // keeps them out of the heading-slug namespace (slugs never contain a
617
+ // hyphenated `fldx-N` unless a heading literally says so).
381
618
  function buildRenderer(seen) {
619
+ let toggleSeq = 0;
382
620
  return {
383
621
  heading(token) {
384
622
  const inner = this.parser.parseInline(token.tokens);
@@ -401,17 +639,27 @@ function buildRenderer(seen) {
401
639
  return `<a href="${escapeHtml(href)}"${title}>${inner}</a>`;
402
640
  },
403
641
 
404
- // Fenced code; gherkin/feature blocks get keyword highlighting.
642
+ // Fenced code; gherkin/feature blocks get keyword highlighting, and an
643
+ // untagged fence that reads as a scenario block (>= 2 keyword-led lines)
644
+ // is highlighted the same way — other-language fences never are.
405
645
  code(token) {
406
646
  const lang = String(token.lang || '').trim().split(/\s+/)[0];
407
647
  let escaped = escapeHtml(token.text);
408
- if (lang === 'gherkin' || lang === 'feature') {
409
- escaped = escaped.replace(GHERKIN_KEYWORDS, '$1<span class="kw">$2</span>');
648
+ if (lang === 'gherkin' || lang === 'feature' || (!lang && looksLikeGherkin(token.text))) {
649
+ escaped = highlightGherkin(escaped);
410
650
  }
411
651
  const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
412
652
  return `<pre><code${cls}>${escaped}\n</code></pre>\n`;
413
653
  },
414
654
 
655
+ // Prose scenario steps (keyword-led <br>-separated lines) get the same
656
+ // keyword colors; ordinary paragraphs pass through marked's default
657
+ // shape, and paragraphs holding raw inline HTML are never rewritten.
658
+ paragraph(token) {
659
+ const inner = this.parser.parseInline(token.tokens);
660
+ return `<p>${hasRawHtmlToken(token.tokens) ? inner : highlightProseSteps(inner)}</p>\n`;
661
+ },
662
+
415
663
  // Task-list checkboxes -> styled spans (same look as the prototype; the
416
664
  // trailing space restores the gap marked v18 no longer inserts itself).
417
665
  checkbox(token) {
@@ -427,7 +675,7 @@ function buildRenderer(seen) {
427
675
  if (headerCells.length < 2 || rows.length === 0) {
428
676
  return plainTableHtml(headerCells, rows);
429
677
  }
430
- return cardsHtml(headerCells, rows);
678
+ return cardsHtml(headerCells, rows, () => `fldx-${toggleSeq++}`);
431
679
  }
432
680
  };
433
681
  }
@@ -540,7 +788,7 @@ function metaCardHtml(meta, fallbackTitle) {
540
788
 
541
789
  // ----------------------------------------------------------------- rendering
542
790
 
543
- function pageHtml({ title, crumb, metaCard, body, footer }) {
791
+ function pageHtml({ title, crumb, metaCard, body, footer, extraCss = '' }) {
544
792
  return `<!DOCTYPE html>
545
793
  ${GENERATED_MARK}
546
794
  <html lang="zh-Hant">
@@ -548,7 +796,7 @@ ${GENERATED_MARK}
548
796
  <meta charset="utf-8">
549
797
  <meta name="viewport" content="width=device-width, initial-scale=1">
550
798
  <title>${title}</title>
551
- <style>${CSS}</style>
799
+ <style>${CSS}${extraCss}</style>
552
800
  </head>
553
801
  <body>
554
802
  <main>
@@ -562,18 +810,33 @@ ${body}
562
810
  `;
563
811
  }
564
812
 
565
- async function renderFile({ MarkedCtor, srcRoot, outRoot, relPosix, stamp, tree, basenames }) {
813
+ async function renderFile({ MarkedCtor, srcRoot, outRoot, relPosix, stamp, tree, basenames, diagramStats }) {
566
814
  const relHtml = `${relPosix.slice(0, -'.md'.length)}.html`;
567
815
  const mdPath = path.join(srcRoot, ...relPosix.split('/'));
568
816
  const outPath = path.join(outRoot, ...relHtml.split('/'));
817
+ const parts = relPosix.split('/');
569
818
 
570
819
  const { meta, body } = splitFrontmatter(await readText(mdPath));
571
820
  const seen = new Map();
572
- const marked = new MarkedCtor({ gfm: true, renderer: buildRenderer(seen) });
821
+ const options = { gfm: true, renderer: buildRenderer(seen) };
822
+ // PROPOSAL-100: only analysis.md is searched for LC-/FL- subsections; the
823
+ // hook inserts each picture or note as its own block before the
824
+ // subsection's first recognised table, so the cards are built exactly as
825
+ // on every other page.
826
+ const diagramPage = parts[parts.length - 1] === 'analysis.md' ? diagrams.newPageState() : null;
827
+ if (diagramPage) {
828
+ options.hooks = { processAllTokens: (tokens) => diagrams.insertDiagrams(tokens, diagramPage) };
829
+ }
830
+ const marked = new MarkedCtor(options);
573
831
  let bodyHtml = marked.parse(preprocess(body));
574
832
  bodyHtml = autolinkCodeMentions(bodyHtml, relPosix, tree, basenames);
575
833
 
576
- const parts = relPosix.split('/');
834
+ const hasDiagramBlock = diagramPage !== null && diagramPage.drawn + diagramPage.notDrawn > 0;
835
+ if (hasDiagramBlock) {
836
+ diagramStats.drawn += diagramPage.drawn;
837
+ diagramStats.notDrawn += diagramPage.notDrawn;
838
+ }
839
+
577
840
  const stem = parts[parts.length - 1].slice(0, -'.md'.length);
578
841
  const indexHref = '../'.repeat(parts.length - 1) + 'index.html';
579
842
  const crumb = `<a href="${indexHref}">specs</a> / ` + parts.map(escapeHtml).join(' / ');
@@ -584,55 +847,567 @@ async function renderFile({ MarkedCtor, srcRoot, outRoot, relPosix, stamp, tree,
584
847
  crumb,
585
848
  metaCard: metaCardHtml(meta, stem),
586
849
  body: bodyHtml,
587
- footer
850
+ footer,
851
+ extraCss: hasDiagramBlock ? diagrams.DIAGRAM_CSS : ''
588
852
  }));
589
853
  return relHtml;
590
854
  }
591
855
 
856
+ // PROPOSAL-079: features/completed/ is a monotonically growing archive, so
857
+ // the root index must not inline it. Entries under the prefix group by the
858
+ // year in their SPEC directory name; anything non-conforming lands in the
859
+ // 'other' bucket (still reachable, never dropped). Grouping is mechanical —
860
+ // directory names only, no frontmatter parsing. Returns null when the tree
861
+ // holds nothing under the prefix (then no pagination happens at all).
862
+ const COMPLETED_PREFIX = 'features/completed/';
863
+ const SPEC_YEAR_RE = /^SPEC-(\d{4})\d{4,}-/;
864
+
865
+ function completedIndexRel(key) {
866
+ return `${COMPLETED_PREFIX}index-${key}.html`;
867
+ }
868
+
869
+ function completedKeyLabel(key) {
870
+ return key === 'other' ? '未分年' : key;
871
+ }
872
+
873
+ // Sort keys: years newest-first, the 'other' bucket always last.
874
+ function completedKeyOrder(keys) {
875
+ return [...keys].sort((a, b) => {
876
+ if (a === 'other') return 1;
877
+ if (b === 'other') return -1;
878
+ return b.localeCompare(a);
879
+ });
880
+ }
881
+
882
+ function groupCompletedByYear(relFiles) {
883
+ const groups = new Map(); // key -> { files: [rel…], units: Set<top-level name> }
884
+ for (const rel of relFiles) {
885
+ if (!rel.startsWith(COMPLETED_PREFIX)) {
886
+ continue;
887
+ }
888
+ const rest = rel.slice(COMPLETED_PREFIX.length);
889
+ const top = rest.split('/')[0];
890
+ const match = rest.includes('/') ? SPEC_YEAR_RE.exec(top) : null;
891
+ const key = match ? match[1] : 'other';
892
+ if (!groups.has(key)) {
893
+ groups.set(key, { files: [], units: new Set() });
894
+ }
895
+ groups.get(key).files.push(rel);
896
+ groups.get(key).units.add(top);
897
+ }
898
+ return groups.size ? groups : null;
899
+ }
900
+
901
+ // PROPOSAL-101: every href on the root index and the year pages goes
902
+ // through here. Inside a relative path only `%` (escape), `#` (fragment)
903
+ // and `?` (query) change what a browser resolves — `a#b.html` points at
904
+ // `a` — so those three are percent-encoded; everything else (CJK, spaces,
905
+ // `&`) stays literal, and a path holding none of the three comes out
906
+ // byte-identical. Callers still escapeHtml() the result for the attribute.
907
+ const HREF_ESCAPES = { '%': '%25', '#': '%23', '?': '%3F' };
908
+
909
+ function indexHref(relPath) {
910
+ return relPath.replace(/[%#?]/g, (ch) => HREF_ESCAPES[ch]);
911
+ }
912
+
913
+ // Root-index year links for the completed/ stub line. Hrefs are root-
914
+ // relative; the surrounding <span> keeps them out of the `li > a` file-icon
915
+ // selector.
916
+ function completedYearLinksHtml(groups) {
917
+ const links = completedKeyOrder(groups.keys()).map((key) => {
918
+ const g = groups.get(key);
919
+ return `<a href="${escapeHtml(indexHref(completedIndexRel(key)))}">${escapeHtml(completedKeyLabel(key))} (${g.units.size})</a>`;
920
+ });
921
+ return `<span class="years">${links.join(' · ')}</span>`;
922
+ }
923
+
592
924
  // Nested <ul> file tree with links to the mirrored .html files.
593
- function buildTreeHtml(relFiles) {
594
- const root = { dirs: new Map(), files: [] };
925
+ // opts.stubs: [{ dirParts, html }] — pseudo-directory lines injected among
926
+ // a node's subdirectories (used for the completed/ year-links line; the
927
+ // node is created even when no other file implies it). opts.topDirsDesc:
928
+ // sort depth-0 directories newest-first (completed year pages).
929
+ function buildTreeHtml(relFiles, opts = {}) {
930
+ const stubs = opts.stubs || [];
931
+ const root = { dirs: new Map(), files: [], stubs: new Map() };
932
+ const ensure = (node, part) => {
933
+ if (!node.dirs.has(part)) {
934
+ node.dirs.set(part, { dirs: new Map(), files: [], stubs: new Map() });
935
+ }
936
+ return node.dirs.get(part);
937
+ };
595
938
  for (const rel of [...relFiles].sort()) {
596
939
  const parts = rel.split('/');
597
940
  let node = root;
598
941
  for (const part of parts.slice(0, -1)) {
599
- if (!node.dirs.has(part)) {
600
- node.dirs.set(part, { dirs: new Map(), files: [] });
601
- }
602
- node = node.dirs.get(part);
942
+ node = ensure(node, part);
603
943
  }
604
944
  node.files.push(rel);
605
945
  }
946
+ for (const stub of stubs) {
947
+ let node = root;
948
+ for (const part of stub.dirParts.slice(0, -1)) {
949
+ node = ensure(node, part);
950
+ }
951
+ node.stubs.set(stub.dirParts[stub.dirParts.length - 1], stub.html);
952
+ }
606
953
 
607
- function emit(node) {
954
+ function emit(node, depth) {
608
955
  let out = '<ul class="tree">';
609
- for (const name of [...node.dirs.keys()].sort()) {
610
- out += `<li><span class="dir">${escapeHtml(name)}/</span>${emit(node.dirs.get(name))}</li>`;
956
+ const names = [...new Set([...node.dirs.keys(), ...node.stubs.keys()])].sort();
957
+ if (depth === 0 && opts.topDirsDesc) {
958
+ names.reverse();
959
+ }
960
+ for (const name of names) {
961
+ if (node.stubs.has(name)) {
962
+ out += `<li><span class="dir">${escapeHtml(name)}/</span> ${node.stubs.get(name)}</li>`;
963
+ } else {
964
+ out += `<li><span class="dir">${escapeHtml(name)}/</span>${emit(node.dirs.get(name), depth + 1)}</li>`;
965
+ }
611
966
  }
612
967
  for (const rel of node.files) {
613
- const href = `${rel.slice(0, -'.md'.length)}.html`;
968
+ const href = indexHref(`${rel.slice(0, -'.md'.length)}.html`);
614
969
  const name = rel.split('/').pop();
615
970
  out += `<li><a href="${escapeHtml(href)}">${escapeHtml(name)}</a></li>`;
616
971
  }
617
972
  return `${out}</ul>`;
618
973
  }
619
974
 
620
- return emit(root);
975
+ return emit(root, 0);
621
976
  }
622
977
 
623
- async function renderIndex({ outRoot, relFiles, title, stamp }) {
624
- const body = `<h1>${escapeHtml(title)}</h1>${buildTreeHtml(relFiles)}`;
978
+ // ---------------------------------------------------------- grouped index
979
+ // PROPOSAL-101: when --src is recognisably a Dflow specs root — it holds
980
+ // shared/_conventions.md, which dflow init always creates; presence only,
981
+ // the content is never read — the root index becomes a grouped directory:
982
+ // a header, jump links, and one native <details> per group, all closed on
983
+ // first open (an only group opens). Grouping, order, role labels and guide
984
+ // text come from paths alone, never from document content, and all live in
985
+ // the registry below. Any other --src keeps the pre-P101 file tree and year
986
+ // pages, changed only by indexHref(): a top-level directory that merely
987
+ // happens to be called `features` or `shared` is no evidence that Dflow's
988
+ // conventions hold there.
989
+ const SPECS_ROOT_MARKER = 'shared/_conventions.md';
990
+ const OVERVIEW_REL = 'shared/_overview.md';
991
+
992
+ // The groups, in page order. `dirs: null` collects root files and every
993
+ // top-level directory no other group claims. A guide line with a `when`
994
+ // test shows only when that test finds what the line talks about.
995
+ const hasFile = (target) => (rels) => rels.includes(target);
996
+ const hasMatch = (re) => (rels) => rels.some((rel) => re.test(rel));
997
+
998
+ const INDEX_GROUPS = [
999
+ {
1000
+ id: 'features',
1001
+ name: 'Features',
1002
+ dirs: ['features'],
1003
+ purpose: '每一次變更的規格與紀錄:進行中、backlog 與已完成',
1004
+ guide: [
1005
+ { html: '一個目錄是一個 feature:<code>_index.md</code> 是它的總覽(目標、範圍、各階段規格與目前的業務規則快照),<code>phase-spec-*.md</code> 是一個階段的完整規格,<code>lightweight-*.md</code> 與 <code>BUG-*.md</code> 是小改動的規格。', when: hasMatch(/^features\/(active|completed)\/[^/]+\//) },
1006
+ { html: '<code>active/</code> 慣例上放還在進行的 feature。', when: hasMatch(/^features\/active\//) },
1007
+ { html: '<code>backlog/</code> 放先記下來的想法或筆記;放在這裡不代表已經排定或核准,要打開看內容才知道還適不適用。', when: hasMatch(/^features\/backlog\//) },
1008
+ { html: '<code>completed/</code> 是已完成 feature 的歷史紀錄,依年份分頁;系統現在的行為以 Domain 那一組的文件為準。', when: hasMatch(/^features\/completed\//) }
1009
+ ]
1010
+ },
1011
+ {
1012
+ id: 'domain',
1013
+ name: 'Domain',
1014
+ dirs: ['domain'],
1015
+ purpose: '系統知識:各 context 的模型、規則、行為與分析',
1016
+ guide: [
1017
+ { html: '「跨 context」那一列是 <code>domain/</code> 直接底下的文件:<code>context-map.md</code> 看各 context 的分工與關係,<code>glossary.md</code> 看用語的定義,<code>analysis.md</code> 記跨 context 的流程、功能與角色的對照,以及不只屬於一個 context 的機制。', when: hasMatch(/^domain\/[^/]+$/) },
1018
+ { html: '其後每一列是 <code>domain/</code> 底下的一個目錄,慣例上一個目錄是一個 bounded context:<code>context-definition.md</code>(或 <code>context.md</code>)講它負責什麼、不負責什麼,<code>models.md</code> 列它的模型,<code>rules.md</code> 是業務規則的索引(BR-ID),<code>behavior.md</code> 用情境寫出目前的行為,<code>events.md</code> 列領域事件,<code>analysis.md</code> 記這個 context 自己的狀態怎麼流轉、數字怎麼算、機制怎麼運作。', when: hasMatch(/^domain\/[^/]+\//) }
1019
+ ]
1020
+ },
1021
+ {
1022
+ id: 'arch',
1023
+ name: '架構與遷移',
1024
+ dirs: ['architecture', 'migration'],
1025
+ purpose: '技術債、架構決策與遷移的紀錄',
1026
+ guide: [
1027
+ { html: '<code>tech-debt.md</code> 記開發途中發現、留待之後處理的技術債。', when: hasMatch(/^(architecture|migration)\/tech-debt\.md$/) },
1028
+ { html: '<code>migration/</code> 底下記的是往目標架構遷移的事。', when: hasMatch(/^migration\//) },
1029
+ { html: '<code>architecture/decisions/</code> 放架構決策紀錄(ADR),一份文件記一個決定。', when: hasMatch(/^architecture\/decisions\//) }
1030
+ ]
1031
+ },
1032
+ {
1033
+ id: 'shared',
1034
+ name: '共用文件',
1035
+ dirs: ['shared'],
1036
+ purpose: '專案層級的總覽、撰寫慣例與 AI 協作指引',
1037
+ guide: [
1038
+ { html: '<code>_overview.md</code> 是這個系統的總覽,第一次讀從它開始。', when: hasFile('shared/_overview.md') },
1039
+ { html: '<code>_conventions.md</code> 記這個專案寫規格的慣例。', when: hasFile('shared/_conventions.md') },
1040
+ { html: '<code>Git-principles-*.md</code> 記這個專案的 Git 分支與提交慣例。', when: hasMatch(/^shared\/Git-principles-[^/]*\.md$/) },
1041
+ { html: '<code>AI-AGENT-GUIDE.md</code> 是給 AI 助手的工作指引。', when: hasFile('shared/AI-AGENT-GUIDE.md') },
1042
+ { html: '<code>dflow-workflows/</code> 是 Dflow 放進來的流程文件與空白範本,由 Dflow 更新,不是這個專案自己寫的文件。', when: hasMatch(/^shared\/dflow-workflows\//) }
1043
+ ]
1044
+ },
1045
+ {
1046
+ id: 'other',
1047
+ name: '其他',
1048
+ dirs: null,
1049
+ purpose: '根目錄的文件與其他頂層目錄,照目錄層級列出',
1050
+ guide: null
1051
+ }
1052
+ ];
1053
+
1054
+ // Recognised filenames list first, in this order; every other file follows
1055
+ // by name. Absent files leave no gap and no hint.
1056
+ const DOMAIN_ROOT_ORDER = ['context-map.md', 'glossary.md', 'analysis.md'];
1057
+ const CONTEXT_ORDER = [
1058
+ 'context-definition.md', 'context.md', 'analysis.md', 'models.md', 'rules.md', 'behavior.md', 'events.md'
1059
+ ];
1060
+
1061
+ // Role labels by exact path shape (`*` = any one segment). A path matching
1062
+ // no shape shows its filename alone — the index never guesses, so a
1063
+ // template analysis.md under shared/ is never labelled as this project's.
1064
+ const ROLE_LABELS = [
1065
+ { shape: ['domain', 'analysis.md'], label: '跨 context 分析' },
1066
+ { shape: ['domain', '*', 'analysis.md'], label: 'context 分析' },
1067
+ { shape: ['features', 'active', '*', '_index.md'], label: 'feature 總覽' }
1068
+ ];
1069
+ // Same idea for a directory's disclosure summary.
1070
+ const DIR_LABELS = [
1071
+ { shape: ['shared', 'dflow-workflows'], label: 'Dflow 放進來的流程文件與範本' }
1072
+ ];
1073
+
1074
+ const INDEX_CSS = `
1075
+ .ix-head { color: var(--soft); font-size: 14px; margin: -0.4rem 0 0.7rem; }
1076
+ .ix-jump { color: var(--faint); font-size: 14px; margin: 0 0 1.2rem; }
1077
+ .ix-jump a { font-weight: 600; text-decoration: none; }
1078
+ .ix-group {
1079
+ background: var(--surface); border: 1px solid var(--line);
1080
+ border-radius: 10px; margin: 0 0 0.7rem;
1081
+ }
1082
+ .ix-group > summary { cursor: pointer; padding: 0.6rem 1rem; line-height: 1.55; }
1083
+ .ix-group[open] > summary { border-bottom: 1px solid var(--line); }
1084
+ .ix-name { font-size: 17px; font-weight: 700; color: var(--head-strong); }
1085
+ .ix-dirs { font-family: Consolas, monospace; font-size: 12.5px; color: var(--faint); margin-left: 0.25em; }
1086
+ .ix-count { font-size: 12.5px; color: var(--soft); margin-left: 0.25em; white-space: nowrap; }
1087
+ .ix-purpose { display: block; font-size: 13.5px; color: var(--soft); }
1088
+ .ix-body { padding: 0.55rem 1rem 0.85rem; }
1089
+ .ix-body, .ix-year { font-size: 13.5px; }
1090
+ .ix-body a, .ix-head a, .ix-year a { font-family: Consolas, monospace; text-decoration: none; overflow-wrap: anywhere; }
1091
+ .ix-body a:hover, .ix-head a:hover, .ix-year a:hover { text-decoration: underline; }
1092
+ .ix-guide {
1093
+ background: var(--accent-soft); border: 1px solid var(--accent-line);
1094
+ border-radius: 8px; padding: 0.3rem 0.8rem; margin: 0 0 0.7rem;
1095
+ }
1096
+ .ix-guide > summary { cursor: pointer; color: var(--accent); font-weight: 600; }
1097
+ .ix-guide ul { margin: 0.35rem 0 0.3rem; padding-left: 1.2rem; }
1098
+ .ix-body h3 {
1099
+ font-family: Consolas, monospace; font-size: 13.5px; color: var(--soft);
1100
+ margin: 0.9rem 0 0.2rem;
1101
+ }
1102
+ .ix-list { list-style: none; padding-left: 0; margin: 0.15rem 0 0.3rem; line-height: 1.9; }
1103
+ .ix-rows { border-top: 1px solid var(--line); margin: 0.3rem 0; }
1104
+ .ix-row {
1105
+ display: grid; grid-template-columns: minmax(7rem, 15rem) 1fr; gap: 0.1rem 1rem;
1106
+ padding: 0.4rem 0; border-bottom: 1px solid var(--line);
1107
+ }
1108
+ .ix-rowname { font-weight: 600; overflow-wrap: anywhere; }
1109
+ .ix-rowname.path { font-family: Consolas, monospace; }
1110
+ .ix-docs { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.1rem 1.1rem; }
1111
+ .ix-docs > details { flex-basis: 100%; }
1112
+ .ix-docs.stack { display: block; }
1113
+ .ix-docs.stack > .ix-list { margin: 0; line-height: 1.75; }
1114
+ .ix-role {
1115
+ font-size: 11px; color: var(--soft); border: 1px solid var(--line);
1116
+ border-radius: 999px; padding: 0 0.45em; margin-left: 0.1em; white-space: nowrap;
1117
+ }
1118
+ .ix-sub { margin: 0.1rem 0; }
1119
+ .ix-sub > summary { cursor: pointer; }
1120
+ .ix-sub > :not(summary) { margin-left: 1.2rem; }
1121
+ .ix-dname { font-family: Consolas, monospace; font-weight: 600; color: var(--soft); }
1122
+ .ix-years a { font-weight: 600; }
1123
+ .ix-note { font-size: 13.5px; color: var(--soft); margin: -0.4rem 0 1rem; }
1124
+ .ix-year .ix-row { grid-template-columns: minmax(7rem, 20rem) 1fr; }
1125
+ @media (max-width: 40rem) {
1126
+ .ix-row, .ix-year .ix-row { grid-template-columns: 1fr; }
1127
+ }
1128
+ `;
1129
+
1130
+ function matchesShape(parts, shape) {
1131
+ return shape.length === parts.length && shape.every((seg, i) => seg === '*' || seg === parts[i]);
1132
+ }
1133
+
1134
+ function pathLabel(table, parts) {
1135
+ const hit = table.find(({ shape }) => matchesShape(parts, shape));
1136
+ return hit ? hit.label : null;
1137
+ }
1138
+
1139
+ // A directory node: `path` is its slash-terminated tree path ('' = root).
1140
+ function buildDirTree(relFiles) {
1141
+ const root = { path: '', dirs: new Map(), files: [] };
1142
+ for (const rel of relFiles) {
1143
+ const parts = rel.split('/');
1144
+ let node = root;
1145
+ for (const part of parts.slice(0, -1)) {
1146
+ if (!node.dirs.has(part)) {
1147
+ node.dirs.set(part, { path: `${node.path}${part}/`, dirs: new Map(), files: [] });
1148
+ }
1149
+ node = node.dirs.get(part);
1150
+ }
1151
+ node.files.push(rel);
1152
+ }
1153
+ return root;
1154
+ }
1155
+
1156
+ function countDocs(node) {
1157
+ let n = node.files.length;
1158
+ for (const child of node.dirs.values()) {
1159
+ n += countDocs(child);
1160
+ }
1161
+ return n;
1162
+ }
1163
+
1164
+ function sortedDirNames(node, skip = []) {
1165
+ return [...node.dirs.keys()].filter((name) => !skip.includes(name)).sort();
1166
+ }
1167
+
1168
+ function orderedFiles(files, fixedOrder = []) {
1169
+ const base = (rel) => rel.split('/').pop();
1170
+ const fixed = fixedOrder.flatMap((name) => files.filter((rel) => base(rel) === name));
1171
+ const rest = files.filter((rel) => !fixedOrder.includes(base(rel))).sort();
1172
+ return [...fixed, ...rest];
1173
+ }
1174
+
1175
+ // A feature directory's files: _index.md first, then every file whose name
1176
+ // carries a date — Dflow names phase specs and lightweight specs
1177
+ // `{kind}-YYYY-MM-DD-{slug}.md` on the day they are created, so date order
1178
+ // is roughly phase order — by that date, then the rest (BUG-{NUMBER}-*,
1179
+ // anything the project named itself) by name. Names only: file contents
1180
+ // and filesystem times never decide the order.
1181
+ const FILE_DATE_RE = /(?:^|[-_])(\d{4}-\d{2}-\d{2})(?=[-_.])/;
1182
+
1183
+ function featureFileOrder(files) {
1184
+ const base = (rel) => rel.split('/').pop();
1185
+ const dateOf = (rel) => {
1186
+ const match = FILE_DATE_RE.exec(base(rel));
1187
+ return match ? match[1] : null;
1188
+ };
1189
+ const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
1190
+ const rest = files.filter((rel) => base(rel) !== '_index.md');
1191
+ return [
1192
+ ...files.filter((rel) => base(rel) === '_index.md'),
1193
+ ...rest.filter((rel) => dateOf(rel)).sort((a, b) => byName(dateOf(a), dateOf(b)) || byName(a, b)),
1194
+ ...rest.filter((rel) => !dateOf(rel)).sort()
1195
+ ];
1196
+ }
1197
+
1198
+ // `base` is the directory the page itself sits in: hrefs are relative to
1199
+ // it, while labels always read the full path.
1200
+ function docLinkHtml(rel, base = '') {
1201
+ const href = indexHref(`${rel.slice(base.length, -'.md'.length)}.html`);
1202
+ const role = pathLabel(ROLE_LABELS, rel.split('/'));
1203
+ const roleHtml = role ? ` <span class="ix-role">${escapeHtml(role)}</span>` : '';
1204
+ return `<a href="${escapeHtml(href)}">${escapeHtml(rel.split('/').pop())}</a>${roleHtml}`;
1205
+ }
1206
+
1207
+ function docListHtml(files, base = '') {
1208
+ return files.length ? `<ul class="ix-list">${files.map((rel) => `<li>${docLinkHtml(rel, base)}</li>`).join('')}</ul>` : '';
1209
+ }
1210
+
1211
+ function docInlineHtml(files) {
1212
+ return files.map((rel) => `<span>${docLinkHtml(rel)}</span>`).join('');
1213
+ }
1214
+
1215
+ // The generic listing every part without a layout of its own uses: files
1216
+ // first by name, then each subdirectory as a named disclosure with its
1217
+ // document count, recursively — every document keeps its own link.
1218
+ function dirListingHtml(node, base = '') {
1219
+ return docListHtml(orderedFiles(node.files), base) +
1220
+ sortedDirNames(node).map((name) => subdirHtml(name, node.dirs.get(name), base)).join('');
1221
+ }
1222
+
1223
+ function subdirHtml(name, node, base = '') {
1224
+ const label = pathLabel(DIR_LABELS, node.path.slice(0, -1).split('/'));
1225
+ return `<details class="ix-sub"><summary><span class="ix-dname">${escapeHtml(name)}/</span> ` +
1226
+ `<span class="ix-count">${countDocs(node)} 份</span>${label ? ` <span class="ix-role">${escapeHtml(label)}</span>` : ''}` +
1227
+ `</summary>${dirListingHtml(node, base)}</details>`;
1228
+ }
1229
+
1230
+ // A row's documents sit side by side (`stacked` false: short conventional
1231
+ // names, as in Domain) or one per line (feature rows: long names whose
1232
+ // order matters).
1233
+ function rowHtml(name, isPath, docsHtml, stacked = false) {
1234
+ return `<div class="ix-row"><div class="ix-rowname${isPath ? ' path' : ''}">${name}</div>` +
1235
+ `<div class="ix-docs${stacked ? ' stack' : ''}">${docsHtml}</div></div>`;
1236
+ }
1237
+
1238
+ // (D) One feature directory as a row: its files one per line in
1239
+ // featureFileOrder, deeper directories as disclosures after them.
1240
+ function featureRowHtml(name, node, base = '') {
1241
+ return rowHtml(`${escapeHtml(name)}/`, true, docListHtml(featureFileOrder(node.files), base) +
1242
+ sortedDirNames(node).map((sub) => subdirHtml(sub, node.dirs.get(sub), base)).join(''), true);
1243
+ }
1244
+
1245
+ // (B) One row for domain/'s own files, then one per subdirectory: its
1246
+ // direct files inline, deeper directories as disclosures.
1247
+ function domainBodyHtml(node) {
1248
+ const rows = [];
1249
+ if (node.files.length) {
1250
+ rows.push(rowHtml('跨 context', false, docInlineHtml(orderedFiles(node.files, DOMAIN_ROOT_ORDER))));
1251
+ }
1252
+ for (const name of sortedDirNames(node)) {
1253
+ const dir = node.dirs.get(name);
1254
+ const nested = sortedDirNames(dir).map((sub) => subdirHtml(sub, dir.dirs.get(sub))).join('');
1255
+ rows.push(rowHtml(`${escapeHtml(name)}/`, true, docInlineHtml(orderedFiles(dir.files, CONTEXT_ORDER)) + nested));
1256
+ }
1257
+ return rows.length ? `<div class="ix-rows">${rows.join('')}</div>` : '';
1258
+ }
1259
+
1260
+ // (D) active/: one feature row per directory. backlog/ lists generically;
1261
+ // completed/ keeps the PROPOSAL-079 year links (its year pages use the same
1262
+ // feature rows, completedRowsHtml).
1263
+ function featuresBodyHtml(node, completedGroups) {
1264
+ let out = docListHtml(orderedFiles(node.files));
1265
+ const active = node.dirs.get('active');
1266
+ if (active) {
1267
+ const rows = sortedDirNames(active).map((name) => featureRowHtml(name, active.dirs.get(name)));
1268
+ out += '<h3>active/</h3>' + docListHtml(orderedFiles(active.files)) +
1269
+ (rows.length ? `<div class="ix-rows">${rows.join('')}</div>` : '');
1270
+ }
1271
+ const backlog = node.dirs.get('backlog');
1272
+ if (backlog) {
1273
+ out += `<h3>backlog/</h3>${dirListingHtml(backlog)}`;
1274
+ }
1275
+ if (completedGroups) {
1276
+ out += `<h3>completed/</h3><p class="ix-years">${completedYearLinksHtml(completedGroups)}</p>`;
1277
+ }
1278
+ return out + sortedDirNames(node, ['active', 'backlog', 'completed'])
1279
+ .map((name) => subdirHtml(name, node.dirs.get(name))).join('');
1280
+ }
1281
+
1282
+ // Everything without a layout of its own: root files first, then one small
1283
+ // heading per top-level directory.
1284
+ function topLevelBodyHtml(rootFiles, dirEntries) {
1285
+ return docListHtml(orderedFiles(rootFiles)) +
1286
+ dirEntries.map(([name, node]) => `<h3>${escapeHtml(name)}/</h3>${dirListingHtml(node)}`).join('');
1287
+ }
1288
+
1289
+ // The grouped index's <h1>..groups body. Pure, and every listing sorts at
1290
+ // output, so the same relFiles in any order give the same HTML.
1291
+ function groupedIndexHtml({ relFiles, title, completedGroups }) {
1292
+ const tree = buildDirTree(relFiles);
1293
+ const claimed = INDEX_GROUPS.flatMap((group) => group.dirs || []);
1294
+ const groups = [];
1295
+ for (const group of INDEX_GROUPS) {
1296
+ const rootFiles = group.dirs === null ? tree.files : [];
1297
+ const dirEntries = (group.dirs || sortedDirNames(tree, claimed))
1298
+ .filter((name) => tree.dirs.has(name))
1299
+ .map((name) => [name, tree.dirs.get(name)]);
1300
+ const count = rootFiles.length + dirEntries.reduce((n, [, node]) => n + countDocs(node), 0);
1301
+ if (count === 0) {
1302
+ continue;
1303
+ }
1304
+ let body;
1305
+ if (group.id === 'features') {
1306
+ body = featuresBodyHtml(dirEntries[0][1], completedGroups);
1307
+ } else if (group.id === 'domain') {
1308
+ body = domainBodyHtml(dirEntries[0][1]);
1309
+ } else {
1310
+ body = topLevelBodyHtml(rootFiles, dirEntries);
1311
+ }
1312
+ const guideLines = (group.guide || []).filter((line) => !line.when || line.when(relFiles));
1313
+ const guide = guideLines.length
1314
+ ? `<details class="ix-guide"><summary>怎麼讀這些文件</summary><ul>${guideLines.map((line) => `<li>${line.html}</li>`).join('')}</ul></details>`
1315
+ : '';
1316
+ const dirs = [...(rootFiles.length ? ['根目錄'] : []), ...dirEntries.map(([name]) => `${escapeHtml(name)}/`)];
1317
+ groups.push({
1318
+ group,
1319
+ html: (open) => `<details class="ix-group" id="g-${group.id}"${open ? ' open' : ''}><summary>` +
1320
+ `<span class="ix-name">${escapeHtml(group.name)}</span> <span class="ix-dirs">${dirs.join(' · ')}</span> ` +
1321
+ `<span class="ix-count">${count} 份</span> <span class="ix-purpose">${escapeHtml(group.purpose)}</span>` +
1322
+ `</summary><div class="ix-body">${guide}${body}</div></details>`
1323
+ });
1324
+ }
1325
+ const start = relFiles.includes(OVERVIEW_REL)
1326
+ ? ` · 從這裡開始:<a href="${escapeHtml(indexHref(`${OVERVIEW_REL.slice(0, -'.md'.length)}.html`))}">${escapeHtml(OVERVIEW_REL)}</a>`
1327
+ : '';
1328
+ const jump = groups.length > 1
1329
+ ? `<p class="ix-jump">跳到:${groups.map(({ group }) => `<a href="#g-${group.id}">${escapeHtml(group.name)}</a>`).join(' · ')}</p>`
1330
+ : '';
1331
+ return `<h1>${escapeHtml(title)}</h1><p class="ix-head">共 ${relFiles.length} 份文件${start}</p>${jump}` +
1332
+ groups.map(({ html }) => html(groups.length === 1)).join('');
1333
+ }
1334
+
1335
+ // (D) A year page on a recognised specs root: one feature row per
1336
+ // directory, newest-first as the PROPOSAL-079 tree lists them, then the
1337
+ // loose files. Hrefs stay relative to the year page.
1338
+ function completedRowsHtml(files) {
1339
+ const node = buildDirTree(files).dirs.get('features').dirs.get('completed');
1340
+ const rows = sortedDirNames(node).reverse()
1341
+ .map((name) => featureRowHtml(name, node.dirs.get(name), COMPLETED_PREFIX));
1342
+ return '<div class="ix-year">' + (rows.length ? `<div class="ix-rows">${rows.join('')}</div>` : '') +
1343
+ docListHtml(orderedFiles(node.files), COMPLETED_PREFIX) + '</div>';
1344
+ }
1345
+
1346
+ // Year-page orientation line (E): only on a recognised specs root.
1347
+ const COMPLETED_NOTE =
1348
+ '這一頁是已完成 feature 的歷史紀錄,依目錄名 SPEC 編號裡的年份分頁,認不出年份的放在「未分年」;' +
1349
+ '系統現在的行為以 <code>domain/</code> 底下的文件為準。';
1350
+
1351
+ async function renderIndex({ outRoot, relFiles, title, stamp, completedGroups, specsRoot }) {
1352
+ let body;
1353
+ if (specsRoot) {
1354
+ body = groupedIndexHtml({ relFiles, title, completedGroups });
1355
+ } else {
1356
+ let tree;
1357
+ if (completedGroups) {
1358
+ const mainFiles = relFiles.filter((rel) => !rel.startsWith(COMPLETED_PREFIX));
1359
+ tree = buildTreeHtml(mainFiles, {
1360
+ stubs: [{ dirParts: ['features', 'completed'], html: completedYearLinksHtml(completedGroups) }]
1361
+ });
1362
+ } else {
1363
+ tree = buildTreeHtml(relFiles);
1364
+ }
1365
+ body = `<h1>${escapeHtml(title)}</h1>${tree}`;
1366
+ }
625
1367
  const footer = `共 ${relFiles.length} 份文件 · ${stamp} · 來源檔較新時請重新執行 dflow render`;
626
1368
  await writeText(path.join(outRoot, 'index.html'), pageHtml({
627
1369
  title: escapeHtml(title),
628
1370
  crumb: 'specs /',
629
1371
  metaCard: '',
630
1372
  body,
631
- footer
1373
+ footer,
1374
+ extraCss: specsRoot ? INDEX_CSS : ''
632
1375
  }));
633
1376
  return 'index.html';
634
1377
  }
635
1378
 
1379
+ // PROPOSAL-079: one physical page per completed year (user requirement:
1380
+ // real files, not visual collapsing — ten years of archive must not pile
1381
+ // into one page). SPEC directories list newest-first inside the page.
1382
+ async function renderCompletedIndex({ outRoot, key, groups, title, stamp, specsRoot }) {
1383
+ const relHtml = completedIndexRel(key);
1384
+ const group = groups.get(key);
1385
+ const stripped = group.files.map((rel) => rel.slice(COMPLETED_PREFIX.length));
1386
+ const label = completedKeyLabel(key);
1387
+ const nav = completedKeyOrder(groups.keys())
1388
+ .map((k) => (k === key
1389
+ ? `<strong>${escapeHtml(completedKeyLabel(k))}</strong>`
1390
+ : `<a href="${escapeHtml(indexHref(`index-${k}.html`))}">${escapeHtml(completedKeyLabel(k))}</a>`))
1391
+ .join(' · ');
1392
+ const body =
1393
+ `<h1>completed / ${escapeHtml(label)}</h1>` +
1394
+ `<p class="yearnav">${nav}</p>` +
1395
+ (specsRoot
1396
+ ? `<p class="ix-note">${COMPLETED_NOTE}</p>${completedRowsHtml(group.files)}`
1397
+ : buildTreeHtml(stripped, { topDirsDesc: true }));
1398
+ const crumb = '<a href="../../index.html">specs</a> / features / completed / ' + escapeHtml(label);
1399
+ const footer = `completed ${escapeHtml(label)} · 共 ${group.units.size} 項 · ${stamp} · 來源檔較新時請重新執行 dflow render`;
1400
+ await writeText(path.join(outRoot, ...relHtml.split('/')), pageHtml({
1401
+ title: escapeHtml(`${title} · completed ${label}`),
1402
+ crumb,
1403
+ metaCard: '',
1404
+ body,
1405
+ footer,
1406
+ extraCss: specsRoot ? INDEX_CSS : ''
1407
+ }));
1408
+ return relHtml;
1409
+ }
1410
+
636
1411
  // ----------------------------------------------------- output-dir ownership
637
1412
 
638
1413
  // Validate a parsed-or-raw manifest. Returns { ok: true, files } or
@@ -748,7 +1523,13 @@ function pathContains(parent, child) {
748
1523
  // a raw EISDIR/ENOTDIR mid-write — after the ownership stamp — so this is
749
1524
  // checked before any output mutation. Returns null, or a human-readable
750
1525
  // description of the first collision found.
751
- function findProjectionCollision(relFiles) {
1526
+ //
1527
+ // reservedPages lists every generated (non-mirror) page THIS run will write
1528
+ // — the root file tree plus any completed year indexes (PROPOSAL-079). Each
1529
+ // is checked against mirror outputs AND against source directories needing
1530
+ // the same path. Defaults to the root index alone so unit callers predate
1531
+ // pagination unchanged.
1532
+ function findProjectionCollision(relFiles, reservedPages = [{ out: 'index.html', what: 'the generated file tree' }]) {
752
1533
  const toOut = (rel) => `${rel.slice(0, -'.md'.length)}.html`;
753
1534
  for (const rel of relFiles) {
754
1535
  // The ledger stores out-relative posix paths and parseManifest rejects
@@ -761,7 +1542,7 @@ function findProjectionCollision(relFiles) {
761
1542
  return `source path ${rel} cannot be represented in the render manifest`;
762
1543
  }
763
1544
  }
764
- const byOut = new Map([['index.html', 'the generated file tree']]);
1545
+ const byOut = new Map(reservedPages.map(({ out, what }) => [out.toLowerCase(), what]));
765
1546
  for (const rel of relFiles) {
766
1547
  const out = toOut(rel);
767
1548
  const prior = byOut.get(out.toLowerCase());
@@ -780,9 +1561,11 @@ function findProjectionCollision(relFiles) {
780
1561
  }
781
1562
  }
782
1563
  }
783
- const indexDir = neededDirs.get('index.html');
784
- if (indexDir) {
785
- return `${indexDir.source} needs ${indexDir.prefix}/ as a directory, but render generates index.html there`;
1564
+ for (const { out } of reservedPages) {
1565
+ const hit = neededDirs.get(out.toLowerCase());
1566
+ if (hit) {
1567
+ return `${hit.source} needs ${hit.prefix}/ as a directory, but render generates ${out} there`;
1568
+ }
786
1569
  }
787
1570
  for (const reserved of [MANIFEST_NAME, MANIFEST_TMP_NAME]) {
788
1571
  const hit = neededDirs.get(reserved);
@@ -1048,7 +1831,17 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
1048
1831
  // all: a colliding projection must refuse cleanly here, not die on a raw
1049
1832
  // fs error mid-write after the ownership stamp (cold-eye gate G3 F2).
1050
1833
  const relMdFiles = await collectMdFiles(srcRoot);
1051
- const collision = findProjectionCollision(relMdFiles);
1834
+ const completedGroups = groupCompletedByYear(relMdFiles);
1835
+ const reservedPages = [{ out: 'index.html', what: 'the generated file tree' }];
1836
+ if (completedGroups) {
1837
+ for (const key of completedKeyOrder(completedGroups.keys())) {
1838
+ reservedPages.push({
1839
+ out: completedIndexRel(key),
1840
+ what: `the generated completed index for ${completedKeyLabel(key)}`
1841
+ });
1842
+ }
1843
+ }
1844
+ const collision = findProjectionCollision(relMdFiles, reservedPages);
1052
1845
  if (collision) {
1053
1846
  stderr.write(
1054
1847
  `refusing to run: ${collision}; nothing was written.\n` +
@@ -1183,7 +1976,10 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
1183
1976
 
1184
1977
  // Ownership (manifest) + entry types (scan) still do not prove a given
1185
1978
  // file is render's own work; the generated marker inside the file does.
1186
- const plannedRels = [...relMdFiles.map((rel) => `${rel.slice(0, -'.md'.length)}.html`), 'index.html'];
1979
+ const plannedRels = [
1980
+ ...relMdFiles.map((rel) => `${rel.slice(0, -'.md'.length)}.html`),
1981
+ ...reservedPages.map(({ out }) => out)
1982
+ ];
1187
1983
  const unproven = await findUnprovenTarget({ outRoot, plannedRels, listedRels: previousFiles });
1188
1984
  if (unproven) {
1189
1985
  stderr.write(
@@ -1209,10 +2005,19 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
1209
2005
 
1210
2006
  const stamp = nowStamp();
1211
2007
  const written = [];
2008
+ const diagramStats = { drawn: 0, notDrawn: 0 };
1212
2009
  for (const relPosix of relMdFiles) {
1213
- written.push(await renderFile({ MarkedCtor: Marked, srcRoot, outRoot, relPosix, stamp, tree, basenames }));
2010
+ written.push(await renderFile({ MarkedCtor: Marked, srcRoot, outRoot, relPosix, stamp, tree, basenames, diagramStats }));
1214
2011
  }
1215
- written.push(await renderIndex({ outRoot, relFiles: relMdFiles, title: options.title, stamp }));
2012
+ // PROPOSAL-101: the grouped index, its guide text and the year-page note
2013
+ // only on a recognised specs root.
2014
+ const specsRoot = tree.has(SPECS_ROOT_MARKER);
2015
+ if (completedGroups) {
2016
+ for (const key of completedKeyOrder(completedGroups.keys())) {
2017
+ written.push(await renderCompletedIndex({ outRoot, key, groups: completedGroups, title: options.title, stamp, specsRoot }));
2018
+ }
2019
+ }
2020
+ written.push(await renderIndex({ outRoot, relFiles: relMdFiles, title: options.title, stamp, completedGroups, specsRoot }));
1216
2021
 
1217
2022
  const stale = staleEntries(previousFiles, written);
1218
2023
  const unlinked = [];
@@ -1235,6 +2040,10 @@ async function runRender({ cwd, args = [], stdout, stderr }) {
1235
2040
  await writeManifest(outRoot, written);
1236
2041
 
1237
2042
  stdout.write(`rendered ${relMdFiles.length} md files -> ${outRoot}\n`);
2043
+ // PROPOSAL-100: only when some LC-/FL- subsection got a picture or a note.
2044
+ if (diagramStats.drawn + diagramStats.notDrawn > 0) {
2045
+ stdout.write(`diagrams: ${diagramStats.drawn} drawn, ${diagramStats.notDrawn} not drawn\n`);
2046
+ }
1238
2047
  stdout.write(`open: ${path.join(outRoot, 'index.html')}\n`);
1239
2048
  return 0;
1240
2049
  }
@@ -1259,5 +2068,12 @@ module.exports = {
1259
2068
  findProjectionCollision,
1260
2069
  GENERATED_MARK,
1261
2070
  splitFrontmatter,
1262
- headingSlug
2071
+ headingSlug,
2072
+ // PROPOSAL-077 A1: exported so tests lock the documented thresholds.
2073
+ LONG_FIELD_CHARS,
2074
+ CLAMP_FIELD_CHARS,
2075
+ // PROPOSAL-079: exported for grouping-edge unit tests.
2076
+ groupCompletedByYear,
2077
+ // PROPOSAL-101: exported so tests can feed shuffled and non-Windows paths.
2078
+ groupedIndexHtml
1263
2079
  };