@majeanson/lac 3.5.1 → 3.5.3

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.
package/dist/index.mjs CHANGED
@@ -135,7 +135,7 @@ function mergeDefs$1(...defs) {
135
135
  for (const def of defs) Object.assign(mergedDescriptors, Object.getOwnPropertyDescriptors(def));
136
136
  return Object.defineProperties({}, mergedDescriptors);
137
137
  }
138
- function esc$14(str) {
138
+ function esc$18(str) {
139
139
  return JSON.stringify(str);
140
140
  }
141
141
  function slugify$1(input) {
@@ -1539,7 +1539,7 @@ const $ZodObjectJIT$1 = /* @__PURE__ */ $constructor$1("$ZodObjectJIT", (inst, d
1539
1539
  ]);
1540
1540
  const normalized = _normalized.value;
1541
1541
  const parseStr = (key) => {
1542
- const k = esc$14(key);
1542
+ const k = esc$18(key);
1543
1543
  return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
1544
1544
  };
1545
1545
  doc.write(`const input = payload.value;`);
@@ -1549,7 +1549,7 @@ const $ZodObjectJIT$1 = /* @__PURE__ */ $constructor$1("$ZodObjectJIT", (inst, d
1549
1549
  doc.write(`const newResult = {};`);
1550
1550
  for (const key of normalized.keys) {
1551
1551
  const id = ids[key];
1552
- const k = esc$14(key);
1552
+ const k = esc$18(key);
1553
1553
  const isOptionalOut = shape[key]?._zod?.optout === "optional";
1554
1554
  doc.write(`const ${id} = ${parseStr(key)};`);
1555
1555
  if (isOptionalOut) doc.write(`
@@ -4115,7 +4115,7 @@ function mergeDefs(...defs) {
4115
4115
  for (const def of defs) Object.assign(mergedDescriptors, Object.getOwnPropertyDescriptors(def));
4116
4116
  return Object.defineProperties({}, mergedDescriptors);
4117
4117
  }
4118
- function esc$13(str) {
4118
+ function esc$17(str) {
4119
4119
  return JSON.stringify(str);
4120
4120
  }
4121
4121
  function slugify(input) {
@@ -5505,7 +5505,7 @@ const $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def)
5505
5505
  ]);
5506
5506
  const normalized = _normalized.value;
5507
5507
  const parseStr = (key) => {
5508
- const k = esc$13(key);
5508
+ const k = esc$17(key);
5509
5509
  return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
5510
5510
  };
5511
5511
  doc.write(`const input = payload.value;`);
@@ -5515,7 +5515,7 @@ const $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def)
5515
5515
  doc.write(`const newResult = {};`);
5516
5516
  for (const key of normalized.keys) {
5517
5517
  const id = ids[key];
5518
- const k = esc$13(key);
5518
+ const k = esc$17(key);
5519
5519
  const isOptionalOut = shape[key]?._zod?.optout === "optional";
5520
5520
  doc.write(`const ${id} = ${parseStr(key)};`);
5521
5521
  if (isOptionalOut) doc.write(`
@@ -10470,7 +10470,7 @@ const doctorCommand = new Command("doctor").description("Check workspace health
10470
10470
  });
10471
10471
  //#endregion
10472
10472
  //#region src/lib/htmlGenerator.ts
10473
- function esc$12(s) {
10473
+ function esc$16(s) {
10474
10474
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
10475
10475
  }
10476
10476
  function generateHtmlWiki(features, projectName, viewLabel, viewName, renderMode) {
@@ -10482,7 +10482,7 @@ function generateHtmlWiki(features, projectName, viewLabel, viewName, renderMode
10482
10482
  <head>
10483
10483
  <meta charset="UTF-8">
10484
10484
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
10485
- <title>${esc$12(projectName)}${viewLabel ? ` · ${esc$12(viewLabel)}` : ""} — LAC Wiki</title>
10485
+ <title>${esc$16(projectName)}${viewLabel ? ` · ${esc$16(viewLabel)}` : ""} — LAC Wiki</title>
10486
10486
  <style>
10487
10487
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10488
10488
 
@@ -11003,7 +11003,7 @@ body { background: var(--bg); color: var(--text); font-family: var(--sans); font
11003
11003
  <div class="topbar">
11004
11004
  <span class="topbar-logo">◈ lac</span>
11005
11005
  <span class="topbar-sep">|</span>
11006
- <span class="topbar-project">${esc$12(projectName)}${viewLabel ? ` <span style="opacity:.55;font-weight:400">· ${esc$12(viewLabel)} view</span>` : ""}</span>
11006
+ <span class="topbar-project">${esc$16(projectName)}${viewLabel ? ` <span style="opacity:.55;font-weight:400">· ${esc$16(viewLabel)} view</span>` : ""}</span>
11007
11007
  <span class="topbar-count">${features.length} features · ${domains.length} domains</span>
11008
11008
  </div>
11009
11009
  <div class="body-row">
@@ -11457,7 +11457,7 @@ window.setSortMode = setSortMode;
11457
11457
  }
11458
11458
  //#endregion
11459
11459
  //#region src/lib/rawHtmlGenerator.ts
11460
- function esc$11(s) {
11460
+ function esc$15(s) {
11461
11461
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
11462
11462
  }
11463
11463
  function generateRawHtml(features, projectName, viewLabel, viewName) {
@@ -11468,7 +11468,7 @@ function generateRawHtml(features, projectName, viewLabel, viewName) {
11468
11468
  <head>
11469
11469
  <meta charset="UTF-8">
11470
11470
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
11471
- <title>${esc$11(projectName)}${viewLabel ? ` · ${esc$11(viewLabel)}` : ""} — LAC Raw</title>
11471
+ <title>${esc$15(projectName)}${viewLabel ? ` · ${esc$15(viewLabel)}` : ""} — LAC Raw</title>
11472
11472
  <style>
11473
11473
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11474
11474
 
@@ -12000,7 +12000,7 @@ body { background: var(--bg); color: var(--text); font-family: var(--sans); font
12000
12000
  <div class="topbar">
12001
12001
  <span class="topbar-logo">◈ lac</span>
12002
12002
  <span class="topbar-sep">|</span>
12003
- <span class="topbar-project">${esc$11(projectName)}${viewLabel ? ` <span style="opacity:.55;font-weight:400">· ${esc$11(viewLabel)} view</span>` : ""}</span>
12003
+ <span class="topbar-project">${esc$15(projectName)}${viewLabel ? ` <span style="opacity:.55;font-weight:400">· ${esc$15(viewLabel)} view</span>` : ""}</span>
12004
12004
  <span class="topbar-badge">raw</span>
12005
12005
  <span class="topbar-count">${features.length} features · ${domains.length} domains</span>
12006
12006
  </div>
@@ -12451,7 +12451,7 @@ async function generateSite(features, outDir, viewLabel, viewName) {
12451
12451
  }
12452
12452
  //#endregion
12453
12453
  //#region src/lib/postcardGenerator.ts
12454
- function esc$10(s) {
12454
+ function esc$14(s) {
12455
12455
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
12456
12456
  }
12457
12457
  function renderInline$1(s) {
@@ -12459,7 +12459,7 @@ function renderInline$1(s) {
12459
12459
  }
12460
12460
  /** Minimal markdown → HTML for postcard body text. */
12461
12461
  function md(text) {
12462
- const lines = esc$10(text).split("\n");
12462
+ const lines = esc$14(text).split("\n");
12463
12463
  const result = [];
12464
12464
  let inList = false;
12465
12465
  for (const line of lines) {
@@ -12521,14 +12521,14 @@ function generatePostcard(feature, projectName) {
12521
12521
  <li class="decision-item">
12522
12522
  <div class="decision-header">
12523
12523
  <span class="decision-index">${i + 1}</span>
12524
- <span class="decision-text">${esc$10(d.decision)}</span>
12525
- ${d.date ? `<span class="decision-date">${esc$10(d.date)}</span>` : ""}
12524
+ <span class="decision-text">${esc$14(d.decision)}</span>
12525
+ ${d.date ? `<span class="decision-date">${esc$14(d.date)}</span>` : ""}
12526
12526
  </div>
12527
- <div class="decision-rationale">${esc$10(d.rationale)}</div>
12527
+ <div class="decision-rationale">${esc$14(d.rationale)}</div>
12528
12528
  ${d.alternativesConsidered && d.alternativesConsidered.length > 0 ? `
12529
12529
  <div class="decision-alts">
12530
12530
  <span class="alts-label">Alternatives considered:</span>
12531
- ${d.alternativesConsidered.map((a) => `<span class="alt-pill">${esc$10(a)}</span>`).join("")}
12531
+ ${d.alternativesConsidered.map((a) => `<span class="alt-pill">${esc$14(a)}</span>`).join("")}
12532
12532
  </div>
12533
12533
  ` : ""}
12534
12534
  </li>
@@ -12544,12 +12544,12 @@ function generatePostcard(feature, projectName) {
12544
12544
  <section class="section">
12545
12545
  <h2 class="section-label">Known Limitations</h2>
12546
12546
  <ul class="limitations-list">
12547
- ${feature.knownLimitations.map((l) => `<li>${esc$10(l)}</li>`).join("")}
12547
+ ${feature.knownLimitations.map((l) => `<li>${esc$14(l)}</li>`).join("")}
12548
12548
  </ul>
12549
12549
  </section>` : "";
12550
12550
  const tagsHtml = feature.tags && feature.tags.length > 0 ? `
12551
12551
  <div class="tags-row">
12552
- ${feature.tags.map((t) => `<span class="tag">${esc$10(t)}</span>`).join("")}
12552
+ ${feature.tags.map((t) => `<span class="tag">${esc$14(t)}</span>`).join("")}
12553
12553
  </div>` : "";
12554
12554
  const priorityHtml = feature.priority != null ? `<span class="meta-pill priority-pill">P${feature.priority}</span>` : "";
12555
12555
  return `<!DOCTYPE html>
@@ -12557,7 +12557,7 @@ function generatePostcard(feature, projectName) {
12557
12557
  <head>
12558
12558
  <meta charset="UTF-8">
12559
12559
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
12560
- <title>${esc$10(feature.title)} — ${esc$10(projectName)}</title>
12560
+ <title>${esc$14(feature.title)} — ${esc$14(projectName)}</title>
12561
12561
  <style>
12562
12562
  :root {
12563
12563
  --bg: #12100e;
@@ -12915,7 +12915,7 @@ function generatePostcard(feature, projectName) {
12915
12915
  <header class="topbar">
12916
12916
  <span class="topbar-logo">◈ lac</span>
12917
12917
  <span class="topbar-project">
12918
- <strong>${esc$10(projectName)}</strong> / ${esc$10(feature.featureKey)}
12918
+ <strong>${esc$14(projectName)}</strong> / ${esc$14(feature.featureKey)}
12919
12919
  </span>
12920
12920
  <span class="topbar-date">${today}</span>
12921
12921
  </header>
@@ -12923,12 +12923,12 @@ function generatePostcard(feature, projectName) {
12923
12923
  <!-- Hero -->
12924
12924
  <div class="hero">
12925
12925
  <div class="badges">
12926
- <span class="badge-status">${esc$10(statusLabel)}</span>
12927
- ${feature.domain ? `<span class="badge-domain">${esc$10(feature.domain)}</span>` : ""}
12926
+ <span class="badge-status">${esc$14(statusLabel)}</span>
12927
+ ${feature.domain ? `<span class="badge-domain">${esc$14(feature.domain)}</span>` : ""}
12928
12928
  ${priorityHtml}
12929
- ${feature.owner ? `<span class="meta-pill">${esc$10(feature.owner)}</span>` : ""}
12929
+ ${feature.owner ? `<span class="meta-pill">${esc$14(feature.owner)}</span>` : ""}
12930
12930
  </div>
12931
- <h1 class="feature-title">${esc$10(feature.title)}</h1>
12931
+ <h1 class="feature-title">${esc$14(feature.title)}</h1>
12932
12932
  </div>
12933
12933
 
12934
12934
  <!-- Body -->
@@ -12951,7 +12951,7 @@ function generatePostcard(feature, projectName) {
12951
12951
  <footer class="postcard-footer">
12952
12952
  <span class="footer-brand">generated via life-as-code</span>
12953
12953
  <span class="footer-sep">·</span>
12954
- <span class="footer-key">${esc$10(feature.featureKey)}</span>
12954
+ <span class="footer-key">${esc$14(feature.featureKey)}</span>
12955
12955
  </footer>
12956
12956
 
12957
12957
  </article>
@@ -12961,7 +12961,7 @@ function generatePostcard(feature, projectName) {
12961
12961
  }
12962
12962
  //#endregion
12963
12963
  //#region src/lib/printGenerator.ts
12964
- function esc$9(s) {
12964
+ function esc$13(s) {
12965
12965
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
12966
12966
  }
12967
12967
  /**
@@ -12977,11 +12977,11 @@ function mdToHtml(text) {
12977
12977
  while (i < lines.length) {
12978
12978
  const line = lines[i] ?? "";
12979
12979
  if (line.startsWith("```")) {
12980
- const lang = esc$9(line.slice(3).trim());
12980
+ const lang = esc$13(line.slice(3).trim());
12981
12981
  const codeLines = [];
12982
12982
  i++;
12983
12983
  while (i < lines.length && !(lines[i] ?? "").startsWith("```")) {
12984
- codeLines.push(esc$9(lines[i] ?? ""));
12984
+ codeLines.push(esc$13(lines[i] ?? ""));
12985
12985
  i++;
12986
12986
  }
12987
12987
  if (i < lines.length) i++;
@@ -12990,24 +12990,24 @@ function mdToHtml(text) {
12990
12990
  continue;
12991
12991
  }
12992
12992
  if (line.startsWith("### ")) {
12993
- out.push(`<h3>${renderInline(esc$9(line.slice(4)))}</h3>`);
12993
+ out.push(`<h3>${renderInline(esc$13(line.slice(4)))}</h3>`);
12994
12994
  i++;
12995
12995
  continue;
12996
12996
  }
12997
12997
  if (line.startsWith("## ")) {
12998
- out.push(`<h2>${renderInline(esc$9(line.slice(3)))}</h2>`);
12998
+ out.push(`<h2>${renderInline(esc$13(line.slice(3)))}</h2>`);
12999
12999
  i++;
13000
13000
  continue;
13001
13001
  }
13002
13002
  if (line.startsWith("# ")) {
13003
- out.push(`<h1>${renderInline(esc$9(line.slice(2)))}</h1>`);
13003
+ out.push(`<h1>${renderInline(esc$13(line.slice(2)))}</h1>`);
13004
13004
  i++;
13005
13005
  continue;
13006
13006
  }
13007
13007
  if (/^[-*] /.test(line)) {
13008
13008
  const items = [];
13009
13009
  while (i < lines.length && /^[-*] /.test(lines[i] ?? "")) {
13010
- items.push(`<li>${renderInline(esc$9((lines[i] ?? "").slice(2)))}</li>`);
13010
+ items.push(`<li>${renderInline(esc$13((lines[i] ?? "").slice(2)))}</li>`);
13011
13011
  i++;
13012
13012
  }
13013
13013
  out.push(`<ul>${items.join("")}</ul>`);
@@ -13016,7 +13016,7 @@ function mdToHtml(text) {
13016
13016
  if (/^[1-9]\d*\. /.test(line)) {
13017
13017
  const items = [];
13018
13018
  while (i < lines.length && /^[1-9]\d*\. /.test(lines[i] ?? "")) {
13019
- items.push(`<li>${renderInline(esc$9((lines[i] ?? "").replace(/^[1-9]\d*\. /, "")))}</li>`);
13019
+ items.push(`<li>${renderInline(esc$13((lines[i] ?? "").replace(/^[1-9]\d*\. /, "")))}</li>`);
13020
13020
  i++;
13021
13021
  }
13022
13022
  out.push(`<ol>${items.join("")}</ol>`);
@@ -13031,7 +13031,7 @@ function mdToHtml(text) {
13031
13031
  paraLines.push(lines[i] ?? "");
13032
13032
  i++;
13033
13033
  }
13034
- if (paraLines.length > 0) out.push(`<p>${renderInline(esc$9(paraLines.join(" ")))}</p>`);
13034
+ if (paraLines.length > 0) out.push(`<p>${renderInline(esc$13(paraLines.join(" ")))}</p>`);
13035
13035
  }
13036
13036
  return out.join("\n");
13037
13037
  }
@@ -13491,8 +13491,8 @@ function buildCoverPage(features, projectName, viewLabel, today) {
13491
13491
  <hr class="page-rule">
13492
13492
  <div class="cover">
13493
13493
  <div class="cover-eyebrow">◈ life-as-code · Feature Documentation</div>
13494
- <div class="cover-title">${esc$9(projectName)}</div>
13495
- <div class="cover-subtitle">${esc$9(viewLabel)}</div>
13494
+ <div class="cover-title">${esc$13(projectName)}</div>
13495
+ <div class="cover-subtitle">${esc$13(viewLabel)}</div>
13496
13496
  <div class="cover-meta">
13497
13497
  <div class="cover-meta-item">
13498
13498
  <strong>Generated</strong>
@@ -13514,7 +13514,7 @@ function buildCoverPage(features, projectName, viewLabel, today) {
13514
13514
  </div>
13515
13515
  </div>
13516
13516
  <div class="page-footer">
13517
- <span class="footer-left">${esc$9(projectName)}</span>
13517
+ <span class="footer-left">${esc$13(projectName)}</span>
13518
13518
  <span class="footer-right">${today}</span>
13519
13519
  </div>
13520
13520
  </div>`;
@@ -13531,14 +13531,14 @@ function buildTocPage(features, projectName, today) {
13531
13531
  return `
13532
13532
  <li class="toc-item">
13533
13533
  <span class="toc-num">${idx + 1}.</span>
13534
- <span class="toc-key">${esc$9(f.featureKey)}</span>
13535
- <span class="toc-feature-title">${esc$9(f.title)}</span>
13536
- <span class="toc-badge" style="color:${color};background:${color}1a">${esc$9(label)}</span>
13534
+ <span class="toc-key">${esc$13(f.featureKey)}</span>
13535
+ <span class="toc-feature-title">${esc$13(f.title)}</span>
13536
+ <span class="toc-badge" style="color:${color};background:${color}1a">${esc$13(label)}</span>
13537
13537
  </li>`;
13538
13538
  }).join("")}
13539
13539
  </ol>
13540
13540
  <div class="page-footer">
13541
- <span class="footer-left">${esc$9(projectName)}</span>
13541
+ <span class="footer-left">${esc$13(projectName)}</span>
13542
13542
  <span class="footer-right">${today}</span>
13543
13543
  </div>
13544
13544
  </div>`;
@@ -13554,11 +13554,11 @@ function buildFeaturePage(feature, projectName, today) {
13554
13554
  <li class="decision-item">
13555
13555
  <div class="decision-header">
13556
13556
  <span class="decision-num">${i + 1}.</span>
13557
- <span class="decision-text">${esc$9(d.decision)}</span>
13558
- ${d.date ? `<span class="decision-date">${esc$9(d.date)}</span>` : ""}
13557
+ <span class="decision-text">${esc$13(d.decision)}</span>
13558
+ ${d.date ? `<span class="decision-date">${esc$13(d.date)}</span>` : ""}
13559
13559
  </div>
13560
- <div class="decision-rationale">${esc$9(d.rationale)}</div>
13561
- ${d.alternativesConsidered && d.alternativesConsidered.length > 0 ? `<div class="decision-alts">Alternatives considered: ${d.alternativesConsidered.map((a) => esc$9(a)).join(", ")}</div>` : ""}
13560
+ <div class="decision-rationale">${esc$13(d.rationale)}</div>
13561
+ ${d.alternativesConsidered && d.alternativesConsidered.length > 0 ? `<div class="decision-alts">Alternatives considered: ${d.alternativesConsidered.map((a) => esc$13(a)).join(", ")}</div>` : ""}
13562
13562
  </li>`).join("")}
13563
13563
  </ol>
13564
13564
  </div>` : "";
@@ -13581,26 +13581,26 @@ function buildFeaturePage(feature, projectName, today) {
13581
13581
  <div class="section">
13582
13582
  <div class="section-label">Known Limitations</div>
13583
13583
  <ul class="limitations-list">
13584
- ${feature.knownLimitations.map((l) => `<li>${esc$9(l)}</li>`).join("")}
13584
+ ${feature.knownLimitations.map((l) => `<li>${esc$13(l)}</li>`).join("")}
13585
13585
  </ul>
13586
13586
  </div>` : "";
13587
13587
  const tagsHtml = feature.tags && feature.tags.length > 0 ? `
13588
13588
  <div class="section">
13589
13589
  <div class="section-label">Tags</div>
13590
13590
  <div class="tags-row">
13591
- ${feature.tags.map((t) => `<span class="tag">${esc$9(t)}</span>`).join("")}
13591
+ ${feature.tags.map((t) => `<span class="tag">${esc$13(t)}</span>`).join("")}
13592
13592
  </div>
13593
13593
  </div>` : "";
13594
13594
  return `
13595
13595
  <div class="page">
13596
13596
  <hr class="page-rule">
13597
13597
  <div class="feature-eyebrow">
13598
- <span class="feature-key">${esc$9(feature.featureKey)}</span>
13599
- <span class="feature-badge" style="color:${color};background:${color}1a">${esc$9(label)}</span>
13600
- ${feature.domain ? `<span class="feature-domain">${esc$9(feature.domain)}</span>` : ""}
13598
+ <span class="feature-key">${esc$13(feature.featureKey)}</span>
13599
+ <span class="feature-badge" style="color:${color};background:${color}1a">${esc$13(label)}</span>
13600
+ ${feature.domain ? `<span class="feature-domain">${esc$13(feature.domain)}</span>` : ""}
13601
13601
  ${feature.priority != null ? `<span class="feature-domain">P${feature.priority}</span>` : ""}
13602
13602
  </div>
13603
- <h1 class="feature-h1">${esc$9(feature.title)}</h1>
13603
+ <h1 class="feature-h1">${esc$13(feature.title)}</h1>
13604
13604
 
13605
13605
  <div class="section">
13606
13606
  <div class="section-label">Problem</div>
@@ -13615,7 +13615,7 @@ function buildFeaturePage(feature, projectName, today) {
13615
13615
  ${tagsHtml}
13616
13616
 
13617
13617
  <div class="page-footer">
13618
- <span class="footer-left">${esc$9(projectName)}</span>
13618
+ <span class="footer-left">${esc$13(projectName)}</span>
13619
13619
  <span class="footer-right">${today}</span>
13620
13620
  </div>
13621
13621
  </div>`;
@@ -13637,7 +13637,7 @@ function generatePrint(features, projectName, viewLabel) {
13637
13637
  <head>
13638
13638
  <meta charset="UTF-8">
13639
13639
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
13640
- <title>${esc$9(projectName)} — ${esc$9(resolvedLabel)}</title>
13640
+ <title>${esc$13(projectName)} — ${esc$13(resolvedLabel)}</title>
13641
13641
  <style>
13642
13642
  ${buildCss(today)}
13643
13643
  </style>
@@ -13653,7 +13653,7 @@ ${featurePages}
13653
13653
  }
13654
13654
  //#endregion
13655
13655
  //#region src/lib/resumeGenerator.ts
13656
- function esc$8(s) {
13656
+ function esc$12(s) {
13657
13657
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
13658
13658
  }
13659
13659
  function today$3() {
@@ -13691,17 +13691,17 @@ function renderStatusBadge() {
13691
13691
  return `<span class="badge badge-frozen">frozen</span>`;
13692
13692
  }
13693
13693
  function renderTagPills(tags) {
13694
- return tags.map((t) => `<span class="pill">${esc$8(t)}</span>`).join("");
13694
+ return tags.map((t) => `<span class="pill">${esc$12(t)}</span>`).join("");
13695
13695
  }
13696
13696
  function renderNpmPills(pkgs) {
13697
- return pkgs.map((p) => `<span class="npm-pill">${esc$8(p)}</span>`).join("");
13697
+ return pkgs.map((p) => `<span class="npm-pill">${esc$12(p)}</span>`).join("");
13698
13698
  }
13699
13699
  function renderDecisions(decisions) {
13700
13700
  return `
13701
13701
  <div class="section-block">
13702
13702
  <span class="section-label">Key decisions:</span>
13703
13703
  <ul class="decision-list">
13704
- ${decisions.slice(0, 3).map((d) => `<li>${esc$8(d.decision)}</li>`).join("\n ")}
13704
+ ${decisions.slice(0, 3).map((d) => `<li>${esc$12(d.decision)}</li>`).join("\n ")}
13705
13705
  ${decisions.length > 3 ? `<li class="more-decisions">+${decisions.length - 3} more decision${decisions.length - 3 === 1 ? "" : "s"}</li>` : ""}
13706
13706
  </ul>
13707
13707
  </div>`;
@@ -13712,12 +13712,12 @@ function renderFeatureCard(f) {
13712
13712
  const successCriteria = f.successCriteria ? `
13713
13713
  <div class="section-block outcome">
13714
13714
  <span class="section-label accent">Outcome:</span>
13715
- <span class="outcome-text">${esc$8(f.successCriteria)}</span>
13715
+ <span class="outcome-text">${esc$12(f.successCriteria)}</span>
13716
13716
  </div>` : "";
13717
13717
  const spawnReason = f.lineage?.spawnReason ? `
13718
13718
  <div class="section-block spawn-note">
13719
13719
  <span class="section-label">Spawned from:</span>
13720
- <span class="spawn-text">${esc$8(f.lineage.spawnReason)}</span>
13720
+ <span class="spawn-text">${esc$12(f.lineage.spawnReason)}</span>
13721
13721
  </div>` : "";
13722
13722
  const npmPackages = f.npmPackages && f.npmPackages.length > 0 ? `
13723
13723
  <div class="section-block">
@@ -13728,8 +13728,8 @@ function renderFeatureCard(f) {
13728
13728
  <article class="feature-card">
13729
13729
  <header class="card-header">
13730
13730
  <div class="card-title-row">
13731
- <h3 class="card-title">${esc$8(f.title)}</h3>
13732
- <code class="feature-key">${esc$8(f.featureKey)}</code>
13731
+ <h3 class="card-title">${esc$12(f.title)}</h3>
13732
+ <code class="feature-key">${esc$12(f.featureKey)}</code>
13733
13733
  </div>
13734
13734
  <div class="card-meta">
13735
13735
  ${renderStatusBadge()}
@@ -13737,7 +13737,7 @@ function renderFeatureCard(f) {
13737
13737
  </div>
13738
13738
  </header>
13739
13739
 
13740
- <blockquote class="problem-block">${esc$8(f.problem)}</blockquote>
13740
+ <blockquote class="problem-block">${esc$12(f.problem)}</blockquote>
13741
13741
  ${decisions}
13742
13742
  ${successCriteria}
13743
13743
  ${spawnReason}
@@ -13749,7 +13749,7 @@ function renderDomainGroup(domain, features) {
13749
13749
  return `
13750
13750
  <section class="domain-group">
13751
13751
  <h2 class="domain-heading">
13752
- <span class="domain-label">${esc$8(domain.toUpperCase())}</span>
13752
+ <span class="domain-label">${esc$12(domain.toUpperCase())}</span>
13753
13753
  <span class="domain-count">${features.length} feature${features.length === 1 ? "" : "s"}</span>
13754
13754
  </h2>
13755
13755
  ${cards}
@@ -13767,7 +13767,7 @@ function renderEmptyState(projectName) {
13767
13767
  have been met.
13768
13768
  </p>
13769
13769
  <p class="empty-hint">
13770
- Run <code>lac advance ${esc$8(projectName)} --status frozen</code> when a feature ships.
13770
+ Run <code>lac advance ${esc$12(projectName)} --status frozen</code> when a feature ships.
13771
13771
  </p>
13772
13772
  </div>
13773
13773
  </main>`;
@@ -13813,7 +13813,7 @@ function generateResume(features, projectName) {
13813
13813
  <head>
13814
13814
  <meta charset="UTF-8">
13815
13815
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
13816
- <title>${esc$8(projectName)} — Feature Portfolio</title>
13816
+ <title>${esc$12(projectName)} — Feature Portfolio</title>
13817
13817
  <style>
13818
13818
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
13819
13819
 
@@ -14258,7 +14258,7 @@ function generateResume(features, projectName) {
14258
14258
  <header class="page-header">
14259
14259
  <div class="header-eyebrow">feature portfolio</div>
14260
14260
  <h1 class="header-title">
14261
- <span class="header-diamond">◈</span>${esc$8(projectName)}
14261
+ <span class="header-diamond">◈</span>${esc$12(projectName)}
14262
14262
  </h1>
14263
14263
  <p class="header-subtitle">
14264
14264
  ${frozen.length} shipped feature${frozen.length === 1 ? "" : "s"}
@@ -17032,7 +17032,7 @@ const FIELDS = [
17032
17032
  }
17033
17033
  ];
17034
17034
  const TOTAL_FIELDS = FIELDS.length;
17035
- function esc$7(s) {
17035
+ function esc$11(s) {
17036
17036
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
17037
17037
  }
17038
17038
  function isFilled(f, field) {
@@ -17109,7 +17109,7 @@ function renderColumnHeaders() {
17109
17109
  ${FIELDS.map((fd) => `
17110
17110
  <th class="col-header" scope="col">
17111
17111
  <div class="col-label-wrap">
17112
- <span class="col-label">${esc$7(fd.label)}</span>
17112
+ <span class="col-label">${esc$11(fd.label)}</span>
17113
17113
  </div>
17114
17114
  </th>`).join("")}
17115
17115
  <th class="pct-header" scope="col">done</th>
@@ -17137,16 +17137,16 @@ function renderFeatureRow(f, idx) {
17137
17137
  data-feature-idx="${idx}"
17138
17138
  data-col-idx="${colIdx}"
17139
17139
  role="gridcell"
17140
- aria-label="${esc$7(fd.key)}: ${filled ? "filled" : "empty"}"
17140
+ aria-label="${esc$11(fd.key)}: ${filled ? "filled" : "empty"}"
17141
17141
  ><span class="cell-inner"></span></td>`;
17142
17142
  }).join("");
17143
17143
  return `
17144
17144
  <tr class="feature-row" data-feature-idx="${idx}">
17145
17145
  <th class="row-header" scope="row">
17146
17146
  <div class="row-header-inner">
17147
- <span class="row-key">${esc$7(f.featureKey)}</span>
17148
- <span class="row-title">${esc$7(f.title)}</span>
17149
- ${domain ? `<span class="row-domain">${esc$7(domain)}</span>` : ""}
17147
+ <span class="row-key">${esc$11(f.featureKey)}</span>
17148
+ <span class="row-title">${esc$11(f.title)}</span>
17149
+ ${domain ? `<span class="row-domain">${esc$11(domain)}</span>` : ""}
17150
17150
  </div>
17151
17151
  </th>
17152
17152
  ${cells}
@@ -17181,7 +17181,7 @@ function generateHeatmap(features, projectName) {
17181
17181
  <head>
17182
17182
  <meta charset="UTF-8">
17183
17183
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
17184
- <title>${esc$7(projectName)} — LAC Heatmap</title>
17184
+ <title>${esc$11(projectName)} — LAC Heatmap</title>
17185
17185
  <style>
17186
17186
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
17187
17187
 
@@ -17636,7 +17636,7 @@ function generateHeatmap(features, projectName) {
17636
17636
  <span>heatmap</span>
17637
17637
  </div>
17638
17638
  <div class="topbar-right">
17639
- <span class="topbar-project">${esc$7(projectName)}</span>
17639
+ <span class="topbar-project">${esc$11(projectName)}</span>
17640
17640
  <span class="topbar-count">${features.length} feature${features.length === 1 ? "" : "s"}</span>
17641
17641
  </div>
17642
17642
  </div>
@@ -17665,13 +17665,13 @@ function generateHeatmap(features, projectName) {
17665
17665
  <div class="footer-block">
17666
17666
  <div class="footer-block-title">Most missed fields</div>
17667
17667
  <ul class="footer-list">
17668
- ${missed.length > 0 ? missed.map((m) => `<li>${esc$7(m)}</li>`).join("\n ") : "<li>All fields well covered</li>"}
17668
+ ${missed.length > 0 ? missed.map((m) => `<li>${esc$11(m)}</li>`).join("\n ") : "<li>All fields well covered</li>"}
17669
17669
  </ul>
17670
17670
  </div>
17671
17671
  <div class="footer-block">
17672
17672
  <div class="footer-block-title">Most complete features</div>
17673
17673
  <ul class="footer-list">
17674
- ${stars.length > 0 ? stars.map((s) => `<li>${esc$7(s)}</li>`).join("\n ") : "<li>No features yet</li>"}
17674
+ ${stars.length > 0 ? stars.map((s) => `<li>${esc$11(s)}</li>`).join("\n ") : "<li>No features yet</li>"}
17675
17675
  </ul>
17676
17676
  </div>
17677
17677
  </div>
@@ -17770,7 +17770,7 @@ function generateHeatmap(features, projectName) {
17770
17770
  }
17771
17771
  //#endregion
17772
17772
  //#region src/lib/diffGenerator.ts
17773
- function esc$6(s) {
17773
+ function esc$10(s) {
17774
17774
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
17775
17775
  }
17776
17776
  const SCALAR_FIELDS = [
@@ -17828,42 +17828,42 @@ function diffFeatures(a, b) {
17828
17828
  return diffs;
17829
17829
  }
17830
17830
  function statusBadge(status) {
17831
- return `<span class="badge badge-${esc$6(status)}">${esc$6(status)}</span>`;
17831
+ return `<span class="badge badge-${esc$10(status)}">${esc$10(status)}</span>`;
17832
17832
  }
17833
17833
  function renderAdded(f) {
17834
17834
  return `<div class="feature-card added">
17835
17835
  <div class="card-header">
17836
17836
  <span class="card-sign added-sign">+</span>
17837
- <span class="feature-key">${esc$6(f.featureKey)}</span>
17837
+ <span class="feature-key">${esc$10(f.featureKey)}</span>
17838
17838
  ${statusBadge(f.status)}
17839
- ${f.domain ? `<span class="domain-badge">${esc$6(f.domain)}</span>` : ""}
17840
- <span class="feature-title">${esc$6(f.title)}</span>
17839
+ ${f.domain ? `<span class="domain-badge">${esc$10(f.domain)}</span>` : ""}
17840
+ <span class="feature-title">${esc$10(f.title)}</span>
17841
17841
  </div>
17842
- <div class="card-problem">${esc$6(f.problem)}</div>
17843
- ${f.tags && f.tags.length ? `<div class="card-tags">${f.tags.map((t) => `<span class="tag">${esc$6(t)}</span>`).join("")}</div>` : ""}
17842
+ <div class="card-problem">${esc$10(f.problem)}</div>
17843
+ ${f.tags && f.tags.length ? `<div class="card-tags">${f.tags.map((t) => `<span class="tag">${esc$10(t)}</span>`).join("")}</div>` : ""}
17844
17844
  </div>`;
17845
17845
  }
17846
17846
  function renderRemoved(f) {
17847
17847
  return `<div class="feature-card removed">
17848
17848
  <div class="card-header">
17849
17849
  <span class="card-sign removed-sign">−</span>
17850
- <span class="feature-key">${esc$6(f.featureKey)}</span>
17850
+ <span class="feature-key">${esc$10(f.featureKey)}</span>
17851
17851
  ${statusBadge(f.status)}
17852
- ${f.domain ? `<span class="domain-badge">${esc$6(f.domain)}</span>` : ""}
17853
- <span class="feature-title">${esc$6(f.title)}</span>
17852
+ ${f.domain ? `<span class="domain-badge">${esc$10(f.domain)}</span>` : ""}
17853
+ <span class="feature-title">${esc$10(f.title)}</span>
17854
17854
  </div>
17855
- <div class="card-problem">${esc$6(f.problem)}</div>
17856
- ${f.tags && f.tags.length ? `<div class="card-tags">${f.tags.map((t) => `<span class="tag">${esc$6(t)}</span>`).join("")}</div>` : ""}
17855
+ <div class="card-problem">${esc$10(f.problem)}</div>
17856
+ ${f.tags && f.tags.length ? `<div class="card-tags">${f.tags.map((t) => `<span class="tag">${esc$10(t)}</span>`).join("")}</div>` : ""}
17857
17857
  </div>`;
17858
17858
  }
17859
17859
  function renderChanged(a, b, diffs) {
17860
17860
  const rows = diffs.map((d) => {
17861
17861
  const oldEmpty = d.oldVal === "";
17862
17862
  const newEmpty = d.newVal === "";
17863
- const oldDisplay = oldEmpty ? "<span class=\"diff-empty\">— not set</span>" : `<pre class="diff-val">${esc$6(d.oldVal.length > 400 ? d.oldVal.slice(0, 400) + "…" : d.oldVal)}</pre>`;
17864
- const newDisplay = newEmpty ? "<span class=\"diff-empty\">— not set</span>" : `<pre class="diff-val">${esc$6(d.newVal.length > 400 ? d.newVal.slice(0, 400) + "…" : d.newVal)}</pre>`;
17863
+ const oldDisplay = oldEmpty ? "<span class=\"diff-empty\">— not set</span>" : `<pre class="diff-val">${esc$10(d.oldVal.length > 400 ? d.oldVal.slice(0, 400) + "…" : d.oldVal)}</pre>`;
17864
+ const newDisplay = newEmpty ? "<span class=\"diff-empty\">— not set</span>" : `<pre class="diff-val">${esc$10(d.newVal.length > 400 ? d.newVal.slice(0, 400) + "…" : d.newVal)}</pre>`;
17865
17865
  return `<tr>
17866
- <td class="diff-field"><code>${esc$6(d.field)}</code></td>
17866
+ <td class="diff-field"><code>${esc$10(d.field)}</code></td>
17867
17867
  <td class="diff-old">${oldDisplay}</td>
17868
17868
  <td class="diff-new">${newDisplay}</td>
17869
17869
  </tr>`;
@@ -17871,10 +17871,10 @@ function renderChanged(a, b, diffs) {
17871
17871
  return `<div class="feature-card changed">
17872
17872
  <div class="card-header">
17873
17873
  <span class="card-sign changed-sign">~</span>
17874
- <span class="feature-key">${esc$6(b.featureKey)}</span>
17874
+ <span class="feature-key">${esc$10(b.featureKey)}</span>
17875
17875
  ${statusBadge(b.status)}
17876
- ${b.domain ? `<span class="domain-badge">${esc$6(b.domain)}</span>` : ""}
17877
- <span class="feature-title">${esc$6(b.title)}</span>
17876
+ ${b.domain ? `<span class="domain-badge">${esc$10(b.domain)}</span>` : ""}
17877
+ <span class="feature-title">${esc$10(b.title)}</span>
17878
17878
  <span class="diff-count">${diffs.length} field${diffs.length === 1 ? "" : "s"} changed</span>
17879
17879
  </div>
17880
17880
  <div class="diff-table-wrap">
@@ -17913,7 +17913,7 @@ function generateDiff(featuresA, featuresB, nameA, nameB) {
17913
17913
  <head>
17914
17914
  <meta charset="UTF-8">
17915
17915
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
17916
- <title>Diff: ${esc$6(nameA)} → ${esc$6(nameB)}</title>
17916
+ <title>Diff: ${esc$10(nameA)} → ${esc$10(nameB)}</title>
17917
17917
  <style>
17918
17918
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
17919
17919
 
@@ -18203,9 +18203,9 @@ body {
18203
18203
  <span class="topbar-logo">◈ lac</span>
18204
18204
  <span class="topbar-sep">|</span>
18205
18205
  <div class="topbar-dirs">
18206
- <span>${esc$6(nameA)}</span>
18206
+ <span>${esc$10(nameA)}</span>
18207
18207
  <span class="topbar-arrow">→</span>
18208
- <span>${esc$6(nameB)}</span>
18208
+ <span>${esc$10(nameB)}</span>
18209
18209
  </div>
18210
18210
  <div class="topbar-right">${date} · ${totalChanges} change${totalChanges === 1 ? "" : "s"}</div>
18211
18211
  </div>
@@ -18257,7 +18257,7 @@ body {
18257
18257
  ` : ""}
18258
18258
 
18259
18259
  ${totalChanges === 0 ? `
18260
- <div class="empty-state">No differences found between ${esc$6(nameA)} and ${esc$6(nameB)}</div>
18260
+ <div class="empty-state">No differences found between ${esc$10(nameA)} and ${esc$10(nameB)}</div>
18261
18261
  ` : ""}
18262
18262
 
18263
18263
  </div>
@@ -18266,7 +18266,7 @@ body {
18266
18266
  }
18267
18267
  //#endregion
18268
18268
  //#region src/lib/treemapGenerator.ts
18269
- function esc$5(s) {
18269
+ function esc$9(s) {
18270
18270
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
18271
18271
  }
18272
18272
  function generateTreemap(features, projectName) {
@@ -18274,13 +18274,13 @@ function generateTreemap(features, projectName) {
18274
18274
  const count = features.length;
18275
18275
  const domainSet = /* @__PURE__ */ new Set();
18276
18276
  for (const f of features) if (f.domain) domainSet.add(f.domain);
18277
- const domainChips = Array.from(domainSet).sort().map((d) => `<button class="domain-chip" data-domain="${esc$5(d)}">${esc$5(d)}</button>`).join("\n ");
18277
+ const domainChips = Array.from(domainSet).sort().map((d) => `<button class="domain-chip" data-domain="${esc$9(d)}">${esc$9(d)}</button>`).join("\n ");
18278
18278
  return `<!DOCTYPE html>
18279
18279
  <html lang="en">
18280
18280
  <head>
18281
18281
  <meta charset="UTF-8">
18282
18282
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
18283
- <title>${esc$5(projectName)} · LAC Treemap</title>
18283
+ <title>${esc$9(projectName)} · LAC Treemap</title>
18284
18284
  <style>
18285
18285
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
18286
18286
  :root{
@@ -18484,7 +18484,7 @@ html,body{
18484
18484
  <div id="topbar">
18485
18485
  <span id="topbar-brand">◈ lac · treemap</span>
18486
18486
  <span id="topbar-sep">|</span>
18487
- <span id="topbar-project">${esc$5(projectName)}</span>
18487
+ <span id="topbar-project">${esc$9(projectName)}</span>
18488
18488
  <span id="topbar-count">${count} feature${count === 1 ? "" : "s"}</span>
18489
18489
  <div id="domain-chips">
18490
18490
  <button id="chip-all">All</button>
@@ -18802,7 +18802,7 @@ buildTiles();
18802
18802
  }
18803
18803
  //#endregion
18804
18804
  //#region src/lib/kanbanGenerator.ts
18805
- function esc$4(s) {
18805
+ function esc$8(s) {
18806
18806
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
18807
18807
  }
18808
18808
  function generateKanban(features, projectName) {
@@ -18810,13 +18810,13 @@ function generateKanban(features, projectName) {
18810
18810
  const count = features.length;
18811
18811
  const domainSet = /* @__PURE__ */ new Set();
18812
18812
  for (const f of features) if (f.domain) domainSet.add(f.domain);
18813
- const domainOptions = Array.from(domainSet).sort().map((d) => `<option value="${esc$4(d)}">${esc$4(d)}</option>`).join("\n ");
18813
+ const domainOptions = Array.from(domainSet).sort().map((d) => `<option value="${esc$8(d)}">${esc$8(d)}</option>`).join("\n ");
18814
18814
  return `<!DOCTYPE html>
18815
18815
  <html lang="en">
18816
18816
  <head>
18817
18817
  <meta charset="UTF-8">
18818
18818
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
18819
- <title>${esc$4(projectName)} · LAC Kanban</title>
18819
+ <title>${esc$8(projectName)} · LAC Kanban</title>
18820
18820
  <style>
18821
18821
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
18822
18822
  :root{
@@ -19050,7 +19050,7 @@ select.ctrl-select:focus{border-color:var(--accent);}
19050
19050
  <div id="topbar">
19051
19051
  <span id="topbar-brand">◈ lac · kanban</span>
19052
19052
  <span id="topbar-sep">|</span>
19053
- <span id="topbar-project">${esc$4(projectName)}</span>
19053
+ <span id="topbar-project">${esc$8(projectName)}</span>
19054
19054
  <span id="topbar-count">${count} feature${count === 1 ? "" : "s"}</span>
19055
19055
  <div id="topbar-controls">
19056
19056
  <span class="ctrl-label">Domain</span>
@@ -19329,7 +19329,7 @@ buildBoard();
19329
19329
  }
19330
19330
  //#endregion
19331
19331
  //#region src/lib/healthGenerator.ts
19332
- function esc$3(s) {
19332
+ function esc$7(s) {
19333
19333
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
19334
19334
  }
19335
19335
  function today$2() {
@@ -19520,7 +19520,7 @@ function renderStatusBar(features) {
19520
19520
  <div class="section-card">
19521
19521
  <h3 class="section-heading">Status Breakdown</h3>
19522
19522
  <div class="status-bar">${segments.map((s) => `<div class="status-segment" style="width:${(s.count / n * 100).toFixed(1)}%;background:${s.color}" title="${s.key}: ${s.count}"></div>`).join("")}</div>
19523
- <div class="status-legend">${segments.map((s) => `<span class="status-legend-item"><span class="status-dot" style="background:${s.color}"></span>${esc$3(s.key)} <strong>${s.count}</strong></span>`).join("")}</div>
19523
+ <div class="status-legend">${segments.map((s) => `<span class="status-legend-item"><span class="status-dot" style="background:${s.color}"></span>${esc$7(s.key)} <strong>${s.count}</strong></span>`).join("")}</div>
19524
19524
  </div>`;
19525
19525
  }
19526
19526
  function renderDomainMiniChart(grouped) {
@@ -19534,7 +19534,7 @@ function renderDomainMiniChart(grouped) {
19534
19534
  const pct = maxCount > 0 ? (fs.length / maxCount * 100).toFixed(1) : "0";
19535
19535
  return `
19536
19536
  <div class="domain-row">
19537
- <span class="domain-name">${esc$3(domain)}</span>
19537
+ <span class="domain-name">${esc$7(domain)}</span>
19538
19538
  <span class="domain-count-label">${fs.length}</span>
19539
19539
  <div class="domain-mini-bar-wrap">
19540
19540
  <div class="domain-mini-bar" style="width:${pct}%"></div>
@@ -19554,7 +19554,7 @@ function renderFieldCoverageBars(features) {
19554
19554
  const color = barColor(pct);
19555
19555
  return `
19556
19556
  <div class="coverage-row">
19557
- <span class="coverage-label">${esc$3(label)}</span>
19557
+ <span class="coverage-label">${esc$7(label)}</span>
19558
19558
  <div class="coverage-bar-wrap">
19559
19559
  <div class="coverage-bar" style="width:${pct}%;background:${color}"></div>
19560
19560
  </div>
@@ -19569,7 +19569,7 @@ function renderTechDebt(features) {
19569
19569
  const noDomain = features.filter((f) => !f.domain);
19570
19570
  function debtList(items, emptyText) {
19571
19571
  if (items.length === 0) return `<div class="debt-empty">&#10003; ${emptyText}</div>`;
19572
- return `<ul class="debt-list">${items.map((f) => `<li><code class="debt-key">${esc$3(f.featureKey)}</code> <span class="debt-title">${esc$3(f.title)}</span></li>`).join("")}</ul>`;
19572
+ return `<ul class="debt-list">${items.map((f) => `<li><code class="debt-key">${esc$7(f.featureKey)}</code> <span class="debt-title">${esc$7(f.title)}</span></li>`).join("")}</ul>`;
19573
19573
  }
19574
19574
  return `
19575
19575
  <div class="section-card debt-card">
@@ -19599,7 +19599,7 @@ function generateHealth(features, projectName) {
19599
19599
  <head>
19600
19600
  <meta charset="UTF-8">
19601
19601
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
19602
- <title>${esc$3(projectName)} — LAC Health</title>
19602
+ <title>${esc$7(projectName)} — LAC Health</title>
19603
19603
  <style>
19604
19604
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
19605
19605
 
@@ -19982,7 +19982,7 @@ body {
19982
19982
  <div class="topbar">
19983
19983
  <span class="topbar-logo">&#9672; lac &middot; health</span>
19984
19984
  <span class="topbar-sep">|</span>
19985
- <span class="topbar-project">${esc$3(projectName)}</span>
19985
+ <span class="topbar-project">${esc$7(projectName)}</span>
19986
19986
  <span class="topbar-date">${today$2()}</span>
19987
19987
  </div>
19988
19988
 
@@ -20041,7 +20041,7 @@ body {
20041
20041
  }
20042
20042
  //#endregion
20043
20043
  //#region src/lib/embedGenerator.ts
20044
- function esc$2(s) {
20044
+ function esc$6(s) {
20045
20045
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20046
20046
  }
20047
20047
  function today$1() {
@@ -20098,7 +20098,7 @@ function generateEmbed(features, projectName) {
20098
20098
  const extraDomains = domains.length > MAX_DOMAINS_SHOWN ? domains.length - MAX_DOMAINS_SHOWN : 0;
20099
20099
  const domainDots = shownDomains.map((d, i) => `<span class="domain-dot-item">
20100
20100
  <span class="domain-dot" style="background:${domainDotColor(i)}"></span>
20101
- <span class="domain-dot-name">${esc$2(d)}</span>
20101
+ <span class="domain-dot-name">${esc$6(d)}</span>
20102
20102
  </span>`).join("");
20103
20103
  const extraBadge = extraDomains > 0 ? `<span class="domain-extra">+${extraDomains} more</span>` : "";
20104
20104
  const iframeCode = `<iframe src="lac-embed.html" width="380" height="320" frameborder="0" style="border-radius:8px;"></iframe>`;
@@ -20107,7 +20107,7 @@ function generateEmbed(features, projectName) {
20107
20107
  <head>
20108
20108
  <meta charset="UTF-8">
20109
20109
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
20110
- <title>${esc$2(projectName)} — LAC Embed</title>
20110
+ <title>${esc$6(projectName)} — LAC Embed</title>
20111
20111
  <style>
20112
20112
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
20113
20113
 
@@ -20388,7 +20388,7 @@ html, body {
20388
20388
  <div class="widget">
20389
20389
 
20390
20390
  <div class="widget-header">
20391
- <div class="widget-brand">&#9672; ${esc$2(projectName)}</div>
20391
+ <div class="widget-brand">&#9672; ${esc$6(projectName)}</div>
20392
20392
  <div class="widget-sub">life-as-code feature map</div>
20393
20393
  </div>
20394
20394
 
@@ -20424,7 +20424,7 @@ html, body {
20424
20424
  <span class="footer-sep">&middot;</span>
20425
20425
  <span class="footer-item">${domainCount} domain${domainCount === 1 ? "" : "s"}</span>
20426
20426
  <span class="footer-sep">&middot;</span>
20427
- <span class="footer-item">Updated: ${esc$2(updated)}</span>
20427
+ <span class="footer-item">Updated: ${esc$6(updated)}</span>
20428
20428
  </div>
20429
20429
 
20430
20430
  <div class="embed-area">
@@ -20433,7 +20433,7 @@ html, body {
20433
20433
  <span>embed code</span>
20434
20434
  </button>
20435
20435
  <div class="embed-code-wrap" id="embed-code-wrap">
20436
- <pre class="embed-pre" id="embed-pre">${esc$2(iframeCode)}</pre>
20436
+ <pre class="embed-pre" id="embed-pre">${esc$6(iframeCode)}</pre>
20437
20437
  <div class="embed-copy-row">
20438
20438
  <button class="embed-copy-btn" id="embed-copy-btn">copy</button>
20439
20439
  </div>
@@ -20483,7 +20483,7 @@ html, body {
20483
20483
  }
20484
20484
  //#endregion
20485
20485
  //#region src/lib/decisionLogGenerator.ts
20486
- function esc$1(s) {
20486
+ function esc$5(s) {
20487
20487
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20488
20488
  }
20489
20489
  function today() {
@@ -20536,7 +20536,7 @@ function mostDecidedFeature(entries) {
20536
20536
  if (entries.length === 0) return "—";
20537
20537
  let best = entries[0];
20538
20538
  for (const e of entries) if (e.decisions.length > best.decisions.length) best = e;
20539
- return `${esc$1(best.featureKey)}: ${best.decisions.length}`;
20539
+ return `${esc$5(best.featureKey)}: ${best.decisions.length}`;
20540
20540
  }
20541
20541
  function mostCommonTagAmongDecisionHeavy(entries) {
20542
20542
  if (entries.length === 0) return "—";
@@ -20547,7 +20547,7 @@ function mostCommonTagAmongDecisionHeavy(entries) {
20547
20547
  for (const e of heavy) for (const t of e.tags) freq.set(t, (freq.get(t) ?? 0) + 1);
20548
20548
  if (freq.size === 0) return "—";
20549
20549
  const top = [...freq.entries()].sort((a, b) => b[1] - a[1])[0];
20550
- return top ? esc$1(top[0]) : "—";
20550
+ return top ? esc$5(top[0]) : "—";
20551
20551
  }
20552
20552
  function renderSidebar(features) {
20553
20553
  return `
@@ -20563,19 +20563,19 @@ function renderSidebar(features) {
20563
20563
  const dimClass = hasDecisions ? "" : " nav-item-dim";
20564
20564
  const countBadge = hasDecisions ? `<span class="nav-dec-count">${f.decisions.length}</span>` : "";
20565
20565
  return `
20566
- <a class="nav-item${dimClass}" href="#${esc$1(f.featureKey)}" data-key="${esc$1(f.featureKey)}"
20567
- data-title="${esc$1(f.title.toLowerCase())}" data-domain="${esc$1(domain.toLowerCase())}">
20566
+ <a class="nav-item${dimClass}" href="#${esc$5(f.featureKey)}" data-key="${esc$5(f.featureKey)}"
20567
+ data-title="${esc$5(f.title.toLowerCase())}" data-domain="${esc$5(domain.toLowerCase())}">
20568
20568
  <span class="nav-dot" style="background:${statusColor(f.status)};opacity:${hasDecisions ? "1" : "0.35"}"></span>
20569
- <span class="nav-item-key">${esc$1(f.featureKey)}</span>
20570
- <span class="nav-item-title">${esc$1(f.title)}</span>
20569
+ <span class="nav-item-key">${esc$5(f.featureKey)}</span>
20570
+ <span class="nav-item-title">${esc$5(f.title)}</span>
20571
20571
  ${countBadge}
20572
20572
  </a>`;
20573
20573
  }).join("");
20574
20574
  return `
20575
- <div class="nav-group" data-domain="${esc$1(domain.toLowerCase())}">
20575
+ <div class="nav-group" data-domain="${esc$5(domain.toLowerCase())}">
20576
20576
  <div class="nav-domain">
20577
20577
  <span class="nav-domain-arrow">&#9660;</span>
20578
- <span class="nav-domain-name">${esc$1(domain)}</span>
20578
+ <span class="nav-domain-name">${esc$5(domain)}</span>
20579
20579
  <span class="nav-domain-count">${fs.length}</span>
20580
20580
  </div>
20581
20581
  <div class="nav-group-items">
@@ -20588,14 +20588,14 @@ function renderSidebar(features) {
20588
20588
  }
20589
20589
  function renderDecisionSection(entry) {
20590
20590
  const decItems = entry.decisions.map((d, i) => {
20591
- const rationale = d.rationale ? `<div class="dec-field"><span class="dec-field-label">Rationale</span><p class="dec-field-body">${esc$1(d.rationale)}</p></div>` : "";
20592
- const alternativesHtml = d.alternativesConsidered && d.alternativesConsidered.length > 0 ? `<div class="dec-field"><span class="dec-field-label">Alternatives considered</span><ul class="dec-alts">${d.alternativesConsidered.map((a) => `<li>${esc$1(a)}</li>`).join("")}</ul></div>` : "";
20593
- const date = d.date ? `<div class="dec-field dec-field-inline"><span class="dec-field-label">Date</span><span class="dec-field-value">${esc$1(d.date)}</span></div>` : "";
20591
+ const rationale = d.rationale ? `<div class="dec-field"><span class="dec-field-label">Rationale</span><p class="dec-field-body">${esc$5(d.rationale)}</p></div>` : "";
20592
+ const alternativesHtml = d.alternativesConsidered && d.alternativesConsidered.length > 0 ? `<div class="dec-field"><span class="dec-field-label">Alternatives considered</span><ul class="dec-alts">${d.alternativesConsidered.map((a) => `<li>${esc$5(a)}</li>`).join("")}</ul></div>` : "";
20593
+ const date = d.date ? `<div class="dec-field dec-field-inline"><span class="dec-field-label">Date</span><span class="dec-field-value">${esc$5(d.date)}</span></div>` : "";
20594
20594
  return `
20595
20595
  <div class="dec-item" data-index="${i}">
20596
20596
  <h4 class="dec-title">
20597
20597
  <span class="dec-num">${i + 1}</span>
20598
- <span class="dec-text">${esc$1(d.decision)}</span>
20598
+ <span class="dec-text">${esc$5(d.decision)}</span>
20599
20599
  </h4>
20600
20600
  ${rationale}
20601
20601
  ${alternativesHtml}
@@ -20603,20 +20603,20 @@ function renderDecisionSection(entry) {
20603
20603
  </div>`;
20604
20604
  }).join("\n");
20605
20605
  return `
20606
- <section class="feature-section" id="${esc$1(entry.featureKey)}"
20607
- data-domain="${esc$1(entry.domain.toLowerCase())}"
20608
- data-key="${esc$1(entry.featureKey.toLowerCase())}"
20606
+ <section class="feature-section" id="${esc$5(entry.featureKey)}"
20607
+ data-domain="${esc$5(entry.domain.toLowerCase())}"
20608
+ data-key="${esc$5(entry.featureKey.toLowerCase())}"
20609
20609
  data-date="${entry.decisions.map((d) => d.date ?? "").filter(Boolean).sort().reverse()[0] ?? ""}">
20610
20610
  <header class="feature-section-header">
20611
20611
  <div class="feature-section-title-row">
20612
- <h2 class="feature-section-title">${esc$1(entry.title)}</h2>
20613
- <code class="feature-section-key">${esc$1(entry.featureKey)}</code>
20612
+ <h2 class="feature-section-title">${esc$5(entry.title)}</h2>
20613
+ <code class="feature-section-key">${esc$5(entry.featureKey)}</code>
20614
20614
  </div>
20615
20615
  <div class="feature-section-meta">
20616
- <span class="domain-chip">${esc$1(entry.domain)}</span>
20616
+ <span class="domain-chip">${esc$5(entry.domain)}</span>
20617
20617
  <span class="status-badge"
20618
20618
  style="color:${statusColor(entry.status)};background:${statusBg(entry.status)};border-color:${statusColor(entry.status)}40">
20619
- ${esc$1(entry.status)}
20619
+ ${esc$5(entry.status)}
20620
20620
  </span>
20621
20621
  <span class="dec-count-badge">${entry.decisions.length} decision${entry.decisions.length === 1 ? "" : "s"}</span>
20622
20622
  </div>
@@ -20657,7 +20657,7 @@ function generateDecisionLog(features, projectName) {
20657
20657
  <head>
20658
20658
  <meta charset="UTF-8">
20659
20659
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
20660
- <title>${esc$1(projectName)} — Decision Log</title>
20660
+ <title>${esc$5(projectName)} — Decision Log</title>
20661
20661
  <style>
20662
20662
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
20663
20663
 
@@ -21100,7 +21100,7 @@ mark {
21100
21100
  <div class="topbar">
21101
21101
  <span class="topbar-logo">&#9672; lac &middot; decisions</span>
21102
21102
  <span class="topbar-sep">|</span>
21103
- <span class="topbar-project">${esc$1(projectName)}</span>
21103
+ <span class="topbar-project">${esc$5(projectName)}</span>
21104
21104
  <span class="topbar-count">${totalDecisions} total decisions</span>
21105
21105
  <div class="sort-controls">
21106
21106
  <span style="font-family:var(--mono);font-size:10px;color:var(--text-soft);margin-right:4px;">sort:</span>
@@ -22919,115 +22919,999 @@ loop();
22919
22919
  </html>`;
22920
22920
  }
22921
22921
  //#endregion
22922
- //#region src/lib/hubGenerator.ts
22923
- /** Canonical ordered entry definitions for all standard LAC outputs. */
22924
- const ALL_HUB_ENTRIES = [
22925
- {
22926
- file: "lac-guide.html",
22927
- label: "User Guide",
22928
- description: "How to use every user-facing feature — generated from userGuide fields",
22929
- icon: "📖",
22930
- primary: true
22931
- },
22932
- {
22933
- file: "lac-story.html",
22934
- label: "Product Story",
22935
- description: "Long-form narrative case study built from feature data",
22936
- icon: "📰",
22937
- primary: true
22938
- },
22939
- {
22940
- file: "lac-release-notes.html",
22941
- label: "Release Notes",
22942
- description: "User-facing release notes — features that shipped recently",
22943
- icon: "🚀",
22944
- primary: true
22945
- },
22946
- {
22947
- file: "lac-sprint.html",
22948
- label: "Sprint Board",
22949
- description: "Active + draft features sorted by priority — sprint planning at a glance",
22950
- icon: "",
22951
- primary: false
22952
- },
22953
- {
22954
- file: "lac-wiki.html",
22955
- label: "Feature Wiki",
22956
- description: "Complete searchable wiki — all fields, all features, sidebar navigation",
22957
- icon: "🗂️",
22958
- primary: false
22959
- },
22960
- {
22961
- file: "lac-kanban.html",
22962
- label: "Kanban Board",
22963
- description: "Active / Frozen / Draft columns with sortable, filterable cards",
22964
- icon: "📋",
22965
- primary: false
22966
- },
22967
- {
22968
- file: "lac-changelog.html",
22969
- label: "Changelog",
22970
- description: "Feature revision history grouped by month — all changes across the workspace",
22971
- icon: "📅",
22972
- primary: false
22973
- },
22974
- {
22975
- file: "lac-decisions.html",
22976
- label: "Decision Log",
22977
- description: "All architectural decisions consolidated and searchable by domain",
22978
- icon: "⚖️",
22979
- primary: false
22980
- },
22981
- {
22982
- file: "lac-api-surface.html",
22983
- label: "API Surface",
22984
- description: "Aggregated public interface reference — all exported components, hooks, and types",
22985
- icon: "🔌",
22986
- primary: false
22987
- },
22988
- {
22989
- file: "lac-depmap.html",
22990
- label: "Dependency Map",
22991
- description: "Runtime cross-feature dependency graph from externalDependencies[]",
22992
- icon: "🕸️",
22993
- primary: false
22994
- },
22995
- {
22996
- file: "lac-health.html",
22997
- label: "Health Scorecard",
22998
- description: "Completeness, coverage, tech-debt score, and field fill rates",
22999
- icon: "🏥",
23000
- primary: false
22922
+ //#region src/lib/views.ts
22923
+ /** Fields always shown at summary density regardless of view */
22924
+ const SUMMARY_FIELDS = new Set([
22925
+ "featureKey",
22926
+ "title",
22927
+ "status",
22928
+ "domain",
22929
+ "priority",
22930
+ "tags",
22931
+ "problem"
22932
+ ]);
22933
+ const VIEW_NAMES = [
22934
+ "dev",
22935
+ "product",
22936
+ "user",
22937
+ "support",
22938
+ "tech"
22939
+ ];
22940
+ /** Always-present identity fields included in every view */
22941
+ const IDENTITY = [
22942
+ "featureKey",
22943
+ "title",
22944
+ "status",
22945
+ "domain"
22946
+ ];
22947
+ const VIEWS = {
22948
+ user: {
22949
+ name: "user",
22950
+ label: "User",
22951
+ description: "Plain-language guide — what the feature does and why it exists",
22952
+ fields: new Set([
22953
+ "title",
22954
+ "problem",
22955
+ "userGuide",
22956
+ "successCriteria",
22957
+ "tags"
22958
+ ])
23001
22959
  },
23002
- {
23003
- file: "lac-heatmap.html",
23004
- label: "Completeness Heatmap",
23005
- description: "Field x feature completeness grid quickly spot gaps",
23006
- icon: "🔥",
23007
- primary: false
22960
+ support: {
22961
+ name: "support",
22962
+ label: "Support",
22963
+ description: "Known limitations, annotations, and escalation context for support teams",
22964
+ fields: new Set([
22965
+ ...IDENTITY,
22966
+ "owner",
22967
+ "problem",
22968
+ "knownLimitations",
22969
+ "annotations",
22970
+ "tags"
22971
+ ])
23008
22972
  },
23009
- {
23010
- file: "lac-graph.html",
23011
- label: "Lineage Graph",
23012
- description: "Interactive force-directed feature lineage graph",
23013
- icon: "🌐",
23014
- primary: false
22973
+ product: {
22974
+ name: "product",
22975
+ label: "Product",
22976
+ description: "Business problem, success criteria, and strategic decisions — no implementation details",
22977
+ fields: new Set([
22978
+ ...IDENTITY,
22979
+ "owner",
22980
+ "priority",
22981
+ "problem",
22982
+ "analysis",
22983
+ "userGuide",
22984
+ "pmSummary",
22985
+ "successCriteria",
22986
+ "acceptanceCriteria",
22987
+ "decisions",
22988
+ "knownLimitations",
22989
+ "tags",
22990
+ "releaseVersion"
22991
+ ])
23015
22992
  },
23016
- {
23017
- file: "lac-print.html",
23018
- label: "Print",
23019
- description: "Print-ready A4 documentall features in clean two-column layout",
23020
- icon: "🖨️",
23021
- primary: false
22993
+ dev: {
22994
+ name: "dev",
22995
+ label: "Developer",
22996
+ description: "Full implementation contextcode, decisions, snippets, and lineage",
22997
+ fields: new Set([
22998
+ ...IDENTITY,
22999
+ "owner",
23000
+ "priority",
23001
+ "problem",
23002
+ "analysis",
23003
+ "implementation",
23004
+ "implementationNotes",
23005
+ "userGuide",
23006
+ "successCriteria",
23007
+ "acceptanceCriteria",
23008
+ "testStrategy",
23009
+ "decisions",
23010
+ "knownLimitations",
23011
+ "tags",
23012
+ "annotations",
23013
+ "lineage",
23014
+ "componentFile",
23015
+ "npmPackages",
23016
+ "publicInterface",
23017
+ "externalDependencies",
23018
+ "codeSnippets"
23019
+ ])
23022
23020
  },
23023
- {
23024
- file: "lac-raw.html",
23025
- label: "Raw Dump",
23026
- description: "Field-by-field dump of every feature.json with sidebar navigation",
23027
- icon: "🔩",
23028
- primary: false
23021
+ tech: {
23022
+ name: "tech",
23023
+ label: "Technical",
23024
+ description: "Complete technical record all fields including history, revisions, and lineage",
23025
+ fields: new Set([
23026
+ ...IDENTITY,
23027
+ "schemaVersion",
23028
+ "owner",
23029
+ "priority",
23030
+ "problem",
23031
+ "analysis",
23032
+ "implementation",
23033
+ "implementationNotes",
23034
+ "userGuide",
23035
+ "pmSummary",
23036
+ "successCriteria",
23037
+ "acceptanceCriteria",
23038
+ "testStrategy",
23039
+ "decisions",
23040
+ "knownLimitations",
23041
+ "tags",
23042
+ "annotations",
23043
+ "lineage",
23044
+ "statusHistory",
23045
+ "revisions",
23046
+ "componentFile",
23047
+ "npmPackages",
23048
+ "publicInterface",
23049
+ "externalDependencies",
23050
+ "codeSnippets",
23051
+ "lastVerifiedDate",
23052
+ "releaseVersion",
23053
+ "superseded_by",
23054
+ "superseded_from",
23055
+ "merged_into",
23056
+ "merged_from"
23057
+ ])
23029
23058
  }
23030
- ];
23059
+ };
23060
+ /**
23061
+ * Return a copy of `feature` with only the keys allowed by `view`.
23062
+ * Fields not in the view's set are omitted entirely.
23063
+ */
23064
+ function applyView(feature, view) {
23065
+ const result = {};
23066
+ for (const key of Object.keys(feature)) if (view.fields.has(key)) result[key] = feature[key];
23067
+ return result;
23068
+ }
23069
+ /**
23070
+ * Fields the HTML wiki renderer requires for sidebar navigation and routing.
23071
+ * These are always preserved regardless of view, so the wiki remains navigable.
23072
+ */
23073
+ const HTML_NAV_FIELDS = new Set([
23074
+ "featureKey",
23075
+ "title",
23076
+ "status",
23077
+ "domain",
23078
+ "lineage",
23079
+ "priority"
23080
+ ]);
23081
+ /**
23082
+ * Like `applyView`, but always preserves HTML navigation fields so the wiki
23083
+ * sidebar and routing continue to work correctly.
23084
+ */
23085
+ function applyViewForHtml(feature, view) {
23086
+ const result = {};
23087
+ for (const key of Object.keys(feature)) if (view.fields.has(key) || HTML_NAV_FIELDS.has(key)) result[key] = feature[key];
23088
+ return result;
23089
+ }
23090
+ /**
23091
+ * Apply density filtering to a feature.
23092
+ * - summary: only SUMMARY_FIELDS (title, status, domain, priority, tags, problem snippet)
23093
+ * - standard: pass through unchanged (generators decide what to show)
23094
+ * - verbose: pass through unchanged but callers should render VERBOSE_EXTRA_FIELDS too
23095
+ *
23096
+ * Returns the filtered feature and the resolved density level.
23097
+ */
23098
+ function applyDensity(feature, density) {
23099
+ if (density === "standard" || density === "verbose") return feature;
23100
+ const result = {};
23101
+ for (const key of Object.keys(feature)) if (SUMMARY_FIELDS.has(key)) if (key === "problem" && typeof feature[key] === "string") {
23102
+ const prob = feature[key];
23103
+ const firstSentence = prob.split(/[.!?]\s/)[0] ?? prob;
23104
+ result[key] = firstSentence.length < prob.length ? firstSentence + "." : prob;
23105
+ } else result[key] = feature[key];
23106
+ return result;
23107
+ }
23108
+ /**
23109
+ * Resolve a view name against both built-in views and custom views from lac.config.json.
23110
+ *
23111
+ * Resolution order:
23112
+ * 1. If `name` matches a key in `customViews` (from lac.config.json), build a ViewConfig from it.
23113
+ * If it has `extends`, merge on top of the built-in base.
23114
+ * 2. Otherwise fall back to VIEWS[name].
23115
+ * 3. If neither matches, return undefined.
23116
+ */
23117
+ function resolveView(name, customViews = {}) {
23118
+ const custom = customViews[name];
23119
+ if (custom) {
23120
+ const base = custom.extends ? VIEWS[custom.extends] : void 0;
23121
+ const baseFields = base ? new Set(base.fields) : /* @__PURE__ */ new Set();
23122
+ const fields = custom.fields ? new Set(custom.fields) : baseFields;
23123
+ for (const f of IDENTITY) fields.add(f);
23124
+ return {
23125
+ name,
23126
+ label: custom.label ?? base?.label ?? name,
23127
+ description: custom.description ?? base?.description ?? `Custom view: ${name}`,
23128
+ fields,
23129
+ density: custom.density,
23130
+ groupBy: custom.groupBy,
23131
+ sortBy: custom.sortBy,
23132
+ filterStatus: custom.filterStatus,
23133
+ sections: custom.sections
23134
+ };
23135
+ }
23136
+ return VIEW_NAMES.includes(name) ? VIEWS[name] : void 0;
23137
+ }
23138
+ /**
23139
+ * Sort and filter a feature list according to a resolved view profile.
23140
+ * This is called before passing features to any generator.
23141
+ */
23142
+ function applyViewTransforms(features, profile) {
23143
+ let result = [...features];
23144
+ if (profile.filterStatus && profile.filterStatus.length > 0) result = result.filter((f) => profile.filterStatus.includes(f["status"]));
23145
+ if (profile.sortBy === "priority") result.sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
23146
+ else if (profile.sortBy === "title") result.sort((a, b) => String(a["title"] ?? "").localeCompare(String(b["title"] ?? "")));
23147
+ else if (profile.sortBy === "status") {
23148
+ const order = {
23149
+ active: 0,
23150
+ draft: 1,
23151
+ frozen: 2,
23152
+ deprecated: 3
23153
+ };
23154
+ result.sort((a, b) => (order[a["status"]] ?? 9) - (order[b["status"]] ?? 9));
23155
+ } else if (profile.sortBy === "lastVerifiedDate") result.sort((a, b) => String(b["lastVerifiedDate"] ?? "").localeCompare(String(a["lastVerifiedDate"] ?? "")));
23156
+ return result;
23157
+ }
23158
+ //#endregion
23159
+ //#region src/lib/dataExportGenerator.ts
23160
+ /**
23161
+ * generateDataExport — produces `lac-data.json`, the universal bridge between
23162
+ * feature.jsons and any app/framework.
23163
+ *
23164
+ * Structure:
23165
+ * meta — project stats, generation timestamp, lac version
23166
+ * features — one entry per feature with all identity fields + a `views` object
23167
+ * containing pre-projected slices for each audience (user, dev, product, support)
23168
+ *
23169
+ * Apps consume this file to surface contextual help, documentation, and tutorials
23170
+ * without knowing about the LAC CLI or the feature.json file format.
23171
+ *
23172
+ * Output: lac-data.json
23173
+ */
23174
+ function generateDataExport(features, projectName, options = {}) {
23175
+ const { lacVersion = "3.5.0", customViews = {} } = options;
23176
+ const customViewConfigs = {};
23177
+ for (const [name, cfg] of Object.entries(customViews)) {
23178
+ const baseFields = cfg.extends ? new Set(VIEWS[cfg.extends]?.fields ?? []) : /* @__PURE__ */ new Set();
23179
+ const fieldSet = cfg.fields ? new Set(cfg.fields) : baseFields;
23180
+ for (const f of [
23181
+ "featureKey",
23182
+ "title",
23183
+ "status",
23184
+ "domain"
23185
+ ]) fieldSet.add(f);
23186
+ customViewConfigs[name] = { fields: fieldSet };
23187
+ }
23188
+ const domains = [...new Set(features.map((f) => f.domain).filter((d) => Boolean(d)))].sort();
23189
+ const definedViews = [
23190
+ "user",
23191
+ "dev",
23192
+ "product",
23193
+ "support",
23194
+ ...Object.keys(customViews)
23195
+ ];
23196
+ const entries = features.map((f) => {
23197
+ const raw = f;
23198
+ const entry = {
23199
+ featureKey: f.featureKey,
23200
+ title: f.title,
23201
+ status: f.status,
23202
+ domain: f.domain,
23203
+ tags: f.tags ?? [],
23204
+ priority: f.priority,
23205
+ externalDependencies: f.externalDependencies ?? []
23206
+ };
23207
+ const views = {};
23208
+ for (const viewName of [
23209
+ "user",
23210
+ "dev",
23211
+ "product",
23212
+ "support"
23213
+ ]) {
23214
+ const viewDef = VIEWS[viewName];
23215
+ const projected = applyView(raw, viewDef);
23216
+ for (const id of [
23217
+ "featureKey",
23218
+ "title",
23219
+ "status",
23220
+ "domain"
23221
+ ]) delete projected[id];
23222
+ if (Object.values(projected).some((v) => v !== void 0 && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0))) views[viewName] = projected;
23223
+ }
23224
+ for (const [viewName, cfg] of Object.entries(customViewConfigs)) {
23225
+ const projected = {};
23226
+ for (const key of Object.keys(raw)) if (cfg.fields.has(key)) projected[key] = raw[key];
23227
+ for (const id of [
23228
+ "featureKey",
23229
+ "title",
23230
+ "status",
23231
+ "domain"
23232
+ ]) delete projected[id];
23233
+ if (Object.values(projected).some((v) => v !== void 0 && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0))) views[viewName] = projected;
23234
+ }
23235
+ entry["views"] = views;
23236
+ return entry;
23237
+ });
23238
+ const output = {
23239
+ meta: {
23240
+ projectName,
23241
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
23242
+ lacVersion,
23243
+ featureCount: features.length,
23244
+ domains,
23245
+ definedViews
23246
+ },
23247
+ features: entries
23248
+ };
23249
+ return JSON.stringify(output, null, 2);
23250
+ }
23251
+ //#endregion
23252
+ //#region src/lib/helpWidgetGenerator.ts
23253
+ /**
23254
+ * generateHelpWidget — produces `lac-help.js`, a zero-dependency vanilla JS bundle
23255
+ * that any web app can include to get contextual in-app help from feature.jsons.
23256
+ *
23257
+ * Usage in any web page:
23258
+ * <script src="/lac/lac-help.js"><\/script>
23259
+ * <script>LacHelp.init({ dataUrl: '/lac/lac-data.json' })<\/script>
23260
+ * <button onclick="LacHelp.show('feat-2026-001')">?</button>
23261
+ *
23262
+ * Or as a Web Component (auto-initialises on first use):
23263
+ * <lac-help feature-key="feat-2026-001" view="user"></lac-help>
23264
+ *
23265
+ * The generated JS is self-contained — no external deps, no build step required.
23266
+ * Reads lac-data.json (generated by `lac export --data`) for feature content.
23267
+ *
23268
+ * Panel UI:
23269
+ * - Slide-in panel (right side, 380px), dark LAC amber design
23270
+ * - Tab bar: User | Dev | Product
23271
+ * - User tab: userGuide (markdown rendered), knownLimitations as "Gotchas"
23272
+ * - Dev tab: componentFile chip, decisions, externalDependencies chips
23273
+ * - Product tab: problem statement, successCriteria
23274
+ * - "Open full guide →" link to /lac/lac-guide.html#featureKey
23275
+ * - Full-text search across all features
23276
+ */
23277
+ function generateHelpWidget(_features, projectName, options = {}) {
23278
+ return `/*!
23279
+ * lac-help.js — generated by @majeanson/lac
23280
+ * Project: ${projectName}
23281
+ * Usage: LacHelp.init({ dataUrl: '/lac/lac-data.json' })
23282
+ * LacHelp.show('featureKey')
23283
+ */
23284
+ (function() {
23285
+ 'use strict';
23286
+
23287
+ var LAC_GUIDE_URL = '${options.guideUrl ?? "./lac-guide.html"}';
23288
+ var _data = null;
23289
+ var _config = { dataUrl: './lac-data.json', defaultView: 'user' };
23290
+ var _panel = null;
23291
+ var _overlay = null;
23292
+ var _currentKey = null;
23293
+ var _currentView = 'user';
23294
+ var _initPromise = null;
23295
+
23296
+ // ── Markdown → HTML (subset) ───────────────────────────────────────────────
23297
+ function mdToHtml(raw) {
23298
+ if (!raw) return '';
23299
+ function escLine(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
23300
+ function inline(s) {
23301
+ return escLine(s)
23302
+ .replace(/\\*\\*\\*(.+?)\\*\\*\\*/g,'<strong><em>$1</em></strong>')
23303
+ .replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>')
23304
+ .replace(/\\*([^*\\n]+?)\\*/g,'<em>$1</em>')
23305
+ .replace(/_([^_\\n]+?)_/g,'<em>$1</em>')
23306
+ .replace(/\`([^\`]+)\`/g,'<code>$1</code>');
23307
+ }
23308
+ var blocks = raw.split(/\\n{2,}/);
23309
+ var out = [];
23310
+ for (var i = 0; i < blocks.length; i++) {
23311
+ var block = blocks[i];
23312
+ var lines = block.split('\\n');
23313
+ var first = lines[0] ? lines[0].trim() : '';
23314
+ if (/^#+\\s/.test(first)) {
23315
+ out.push('<p class="lh-subhead">'+inline(first.replace(/^#+\\s+/,''))+'</p>');
23316
+ } else if (lines.every(function(l){return /^\\s*[-*]\\s/.test(l);})) {
23317
+ out.push('<ul class="lh-list">'+lines.map(function(l){
23318
+ return '<li>'+inline(l.replace(/^\\s*[-*]\\s+/,''))+'</li>';
23319
+ }).join('')+'</ul>');
23320
+ } else {
23321
+ out.push('<p class="lh-p">'+inline(block.replace(/\\n/g,' '))+'</p>');
23322
+ }
23323
+ }
23324
+ return out.join('');
23325
+ }
23326
+
23327
+ // ── CSS ────────────────────────────────────────────────────────────────────
23328
+ var CSS = \`
23329
+ #lac-help-overlay{position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0);pointer-events:none;transition:background 0.25s}
23330
+ #lac-help-overlay.open{background:rgba(0,0,0,0.35);pointer-events:auto}
23331
+ #lac-help-panel{
23332
+ position:fixed;top:0;right:0;bottom:0;width:380px;max-width:100vw;z-index:9999;
23333
+ background:#0f0d0b;border-left:1px solid #262018;
23334
+ display:flex;flex-direction:column;
23335
+ transform:translateX(100%);transition:transform 0.28s cubic-bezier(0.4,0,0.2,1);
23336
+ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
23337
+ font-size:13px;color:#ece3d8;
23338
+ box-shadow:-8px 0 32px rgba(0,0,0,0.5);
23339
+ }
23340
+ #lac-help-panel.open{transform:translateX(0)}
23341
+ .lh-topbar{
23342
+ display:flex;align-items:center;gap:10px;padding:0 16px;height:46px;
23343
+ background:#0b0a08;border-bottom:1px solid #262018;flex-shrink:0;
23344
+ }
23345
+ .lh-brand{font-family:monospace;font-size:11px;color:#c4a255;letter-spacing:0.06em}
23346
+ .lh-title{font-size:12px;color:#b0a494;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
23347
+ .lh-close{
23348
+ width:28px;height:28px;border-radius:6px;border:1px solid #262018;
23349
+ background:transparent;color:#736455;font-size:16px;cursor:pointer;
23350
+ display:flex;align-items:center;justify-content:center;flex-shrink:0;
23351
+ transition:color 0.15s,border-color 0.15s;
23352
+ }
23353
+ .lh-close:hover{color:#ece3d8;border-color:#736455}
23354
+ .lh-tabs{
23355
+ display:flex;border-bottom:1px solid #262018;flex-shrink:0;background:#0b0a08;
23356
+ }
23357
+ .lh-tab{
23358
+ flex:1;padding:10px 4px;font-size:11px;letter-spacing:0.04em;text-align:center;
23359
+ color:#736455;cursor:pointer;border-bottom:2px solid transparent;
23360
+ transition:color 0.15s,border-color 0.15s;background:none;border-top:none;border-left:none;border-right:none;
23361
+ }
23362
+ .lh-tab.active{color:#c4a255;border-bottom-color:#c4a255}
23363
+ .lh-tab:hover:not(.active){color:#b0a494}
23364
+ .lh-body{flex:1;overflow-y:auto;padding:18px 16px 24px;scrollbar-width:thin;scrollbar-color:#262018 transparent}
23365
+ .lh-body::-webkit-scrollbar{width:4px}
23366
+ .lh-body::-webkit-scrollbar-track{background:transparent}
23367
+ .lh-body::-webkit-scrollbar-thumb{background:#262018;border-radius:2px}
23368
+ .lh-section{margin-bottom:18px}
23369
+ .lh-label{
23370
+ font-family:monospace;font-size:9px;letter-spacing:0.14em;text-transform:uppercase;
23371
+ color:#736455;margin-bottom:7px;
23372
+ }
23373
+ .lh-p{margin-bottom:8px;line-height:1.6;color:#b0a494}
23374
+ .lh-subhead{font-size:12px;font-weight:600;color:#ece3d8;margin-bottom:5px;margin-top:10px}
23375
+ .lh-list{padding-left:16px;margin-bottom:8px}
23376
+ .lh-list li{line-height:1.6;color:#b0a494;margin-bottom:2px}
23377
+ code{font-family:monospace;font-size:11px;background:#181512;padding:1px 5px;border-radius:3px;color:#c4a255}
23378
+ .lh-chip{
23379
+ display:inline-flex;align-items:center;gap:4px;
23380
+ padding:3px 9px;margin:2px 3px 2px 0;border-radius:4px;
23381
+ background:#181512;border:1px solid #262018;
23382
+ font-family:monospace;font-size:10px;color:#b0a494;
23383
+ }
23384
+ .lh-chip-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
23385
+ .lh-decision{
23386
+ padding:10px 12px;border-radius:6px;background:#181512;border:1px solid #262018;
23387
+ margin-bottom:8px;
23388
+ }
23389
+ .lh-decision-q{font-size:12px;font-weight:600;color:#ece3d8;margin-bottom:4px;line-height:1.4}
23390
+ .lh-decision-r{font-size:11px;color:#736455;line-height:1.5}
23391
+ .lh-limitation{
23392
+ padding:8px 10px;border-radius:5px;background:rgba(204,91,91,0.08);
23393
+ border-left:2px solid rgba(204,91,91,0.4);margin-bottom:6px;
23394
+ font-size:12px;color:#b0a494;line-height:1.5;
23395
+ }
23396
+ .lh-guide-link{
23397
+ display:flex;align-items:center;justify-content:center;gap:6px;
23398
+ margin-top:20px;padding:10px;border-radius:7px;border:1px solid #262018;
23399
+ font-size:12px;color:#c4a255;text-decoration:none;
23400
+ transition:border-color 0.15s,background 0.15s;
23401
+ }
23402
+ .lh-guide-link:hover{border-color:#c4a255;background:rgba(196,162,85,0.06)}
23403
+ .lh-search-row{padding:10px 12px;border-bottom:1px solid #262018;flex-shrink:0;background:#0b0a08}
23404
+ .lh-search{
23405
+ width:100%;padding:7px 10px;border-radius:6px;border:1px solid #262018;
23406
+ background:#181512;color:#ece3d8;font-size:12px;outline:none;
23407
+ transition:border-color 0.15s;box-sizing:border-box;
23408
+ }
23409
+ .lh-search:focus{border-color:#c4a255}
23410
+ .lh-search::placeholder{color:#736455}
23411
+ .lh-search-results{
23412
+ position:absolute;left:0;right:0;top:100%;z-index:1;
23413
+ background:#181512;border:1px solid #262018;border-radius:0 0 8px 8px;
23414
+ max-height:240px;overflow-y:auto;display:none;
23415
+ }
23416
+ .lh-search-results.visible{display:block}
23417
+ .lh-sr-item{
23418
+ padding:10px 14px;cursor:pointer;border-bottom:1px solid #1a1714;
23419
+ transition:background 0.1s;
23420
+ }
23421
+ .lh-sr-item:last-child{border-bottom:none}
23422
+ .lh-sr-item:hover{background:#1e1a16}
23423
+ .lh-sr-title{font-size:12px;font-weight:600;color:#ece3d8;margin-bottom:2px}
23424
+ .lh-sr-domain{font-family:monospace;font-size:10px;color:#736455}
23425
+ .lh-empty{font-size:12px;color:#736455;font-style:italic;padding:8px 0}
23426
+ .lh-footer{
23427
+ flex-shrink:0;padding:10px 16px;border-top:1px solid #262018;
23428
+ font-family:monospace;font-size:9px;color:#736455;letter-spacing:0.05em;text-align:center;background:#0b0a08;
23429
+ }
23430
+ \`;
23431
+
23432
+ // ── DOM bootstrap ──────────────────────────────────────────────────────────
23433
+ function injectStyles() {
23434
+ if (document.getElementById('lac-help-styles')) return;
23435
+ var s = document.createElement('style');
23436
+ s.id = 'lac-help-styles';
23437
+ s.textContent = CSS;
23438
+ document.head.appendChild(s);
23439
+ }
23440
+
23441
+ function buildPanel() {
23442
+ injectStyles();
23443
+
23444
+ _overlay = document.createElement('div');
23445
+ _overlay.id = 'lac-help-overlay';
23446
+ _overlay.addEventListener('click', function(e) {
23447
+ if (e.target === _overlay) LacHelp.hide();
23448
+ });
23449
+
23450
+ _panel = document.createElement('div');
23451
+ _panel.id = 'lac-help-panel';
23452
+ _panel.setAttribute('role', 'dialog');
23453
+ _panel.setAttribute('aria-modal', 'true');
23454
+ _panel.innerHTML =
23455
+ '<div class="lh-topbar">' +
23456
+ '<span class="lh-brand">lac</span>' +
23457
+ '<span class="lh-title" id="lh-title"></span>' +
23458
+ '<button class="lh-close" id="lh-close" aria-label="Close help">✕</button>' +
23459
+ '</div>' +
23460
+ '<div class="lh-search-row" style="position:relative">' +
23461
+ '<input class="lh-search" id="lh-search" type="search" placeholder="Search features…" autocomplete="off">' +
23462
+ '<div class="lh-search-results" id="lh-sr"></div>' +
23463
+ '</div>' +
23464
+ '<div class="lh-tabs" id="lh-tabs"></div>' +
23465
+ '<div class="lh-body" id="lh-body"></div>' +
23466
+ '<div class="lh-footer">Generated by <strong style="color:#c4a255">@majeanson/lac</strong></div>';
23467
+
23468
+ document.body.appendChild(_overlay);
23469
+ document.body.appendChild(_panel);
23470
+
23471
+ document.getElementById('lh-close').addEventListener('click', function() { LacHelp.hide(); });
23472
+
23473
+ // Search
23474
+ var searchEl = document.getElementById('lh-search');
23475
+ var srEl = document.getElementById('lh-sr');
23476
+ searchEl.addEventListener('input', function() {
23477
+ var q = searchEl.value.trim().toLowerCase();
23478
+ if (!q || !_data) { srEl.innerHTML = ''; srEl.classList.remove('visible'); return; }
23479
+ var results = LacHelp.search(q).slice(0, 8);
23480
+ if (!results.length) {
23481
+ srEl.innerHTML = '<div class="lh-sr-item"><div class="lh-sr-title" style="color:#736455">No results</div></div>';
23482
+ } else {
23483
+ srEl.innerHTML = results.map(function(f) {
23484
+ return '<div class="lh-sr-item" data-key="'+f.featureKey+'">' +
23485
+ '<div class="lh-sr-title">'+escHtml(f.title)+'</div>' +
23486
+ '<div class="lh-sr-domain">'+(f.domain||'').replace(/-/g,' ')+' · '+f.status+'</div>' +
23487
+ '</div>';
23488
+ }).join('');
23489
+ }
23490
+ srEl.classList.add('visible');
23491
+ srEl.querySelectorAll('.lh-sr-item[data-key]').forEach(function(el) {
23492
+ el.addEventListener('click', function() {
23493
+ LacHelp.show(el.getAttribute('data-key'));
23494
+ searchEl.value = '';
23495
+ srEl.innerHTML = '';
23496
+ srEl.classList.remove('visible');
23497
+ });
23498
+ });
23499
+ });
23500
+ searchEl.addEventListener('blur', function() {
23501
+ setTimeout(function() { srEl.classList.remove('visible'); }, 150);
23502
+ });
23503
+
23504
+ // Keyboard: Escape closes
23505
+ document.addEventListener('keydown', function(e) {
23506
+ if (e.key === 'Escape') LacHelp.hide();
23507
+ });
23508
+ }
23509
+
23510
+ function escHtml(s) {
23511
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
23512
+ }
23513
+
23514
+ // ── Data loading ───────────────────────────────────────────────────────────
23515
+ function loadData() {
23516
+ if (_initPromise) return _initPromise;
23517
+ _initPromise = fetch(_config.dataUrl)
23518
+ .then(function(r) {
23519
+ if (!r.ok) throw new Error('lac-data.json not found at '+_config.dataUrl);
23520
+ return r.json();
23521
+ })
23522
+ .then(function(json) {
23523
+ _data = json;
23524
+ return json;
23525
+ })
23526
+ .catch(function(err) {
23527
+ console.warn('[lac-help] Could not load feature data:', err.message);
23528
+ _data = { features: [] };
23529
+ });
23530
+ return _initPromise;
23531
+ }
23532
+
23533
+ // ── Render ─────────────────────────────────────────────────────────────────
23534
+ var DOMAIN_PALETTE = {
23535
+ recording:'#cc8a4a',auth:'#5b82cc',band:'#4aad72',sessions:'#9b6fc4',
23536
+ versioning:'#4ab5cc',editing:'#e8674a',render:'#4accaa',collaboration:'#c4a255',
23537
+ storage:'#a2cc4a','app-shell':'#736455',
23538
+ };
23539
+
23540
+ function domainColor(domain) {
23541
+ return DOMAIN_PALETTE[domain] || '#c4a255';
23542
+ }
23543
+
23544
+ function renderView(feature, viewName) {
23545
+ var v = (feature.views || {})[viewName] || {};
23546
+ var html = '';
23547
+
23548
+ if (viewName === 'user') {
23549
+ if (v.userGuide) {
23550
+ html += '<div class="lh-section"><div class="lh-label">How to use</div>'+mdToHtml(v.userGuide)+'</div>';
23551
+ } else if (v.problem) {
23552
+ html += '<div class="lh-section"><div class="lh-label">What it does</div>'+mdToHtml(v.problem)+'</div>';
23553
+ } else {
23554
+ html += '<div class="lh-empty">No user guide available for this feature yet.</div>';
23555
+ }
23556
+ if (v.knownLimitations && v.knownLimitations.length) {
23557
+ html += '<div class="lh-section"><div class="lh-label">Gotchas</div>';
23558
+ for (var i=0;i<v.knownLimitations.length;i++) {
23559
+ html += '<div class="lh-limitation">'+escHtml(v.knownLimitations[i])+'</div>';
23560
+ }
23561
+ html += '</div>';
23562
+ }
23563
+ }
23564
+
23565
+ if (viewName === 'dev') {
23566
+ if (v.componentFile) {
23567
+ html += '<div class="lh-section"><div class="lh-label">Component file</div>' +
23568
+ '<span class="lh-chip">📄 '+escHtml(v.componentFile)+'</span></div>';
23569
+ }
23570
+ if (feature.externalDependencies && feature.externalDependencies.length) {
23571
+ html += '<div class="lh-section"><div class="lh-label">Depends on ('+feature.externalDependencies.length+')</div>';
23572
+ for (var i=0;i<feature.externalDependencies.length;i++) {
23573
+ var depKey = feature.externalDependencies[i];
23574
+ var dep = _data ? _data.features.find(function(f){return f.featureKey===depKey;}) : null;
23575
+ var col = dep ? domainColor(dep.domain) : '#736455';
23576
+ html += '<span class="lh-chip" style="cursor:pointer" onclick="LacHelp.show(\''+depKey+'\')">'+
23577
+ '<span class="lh-chip-dot" style="background:'+col+'"></span>'+
23578
+ escHtml(dep ? dep.title : depKey)+'</span>';
23579
+ }
23580
+ html += '</div>';
23581
+ }
23582
+ if (v.decisions && v.decisions.length) {
23583
+ html += '<div class="lh-section"><div class="lh-label">Key decisions</div>';
23584
+ for (var i=0;i<v.decisions.length;i++) {
23585
+ var d = v.decisions[i];
23586
+ html += '<div class="lh-decision">'+
23587
+ '<div class="lh-decision-q">'+escHtml(d.decision||'')+'</div>'+
23588
+ '<div class="lh-decision-r">'+escHtml(d.rationale||'')+'</div>'+
23589
+ '</div>';
23590
+ }
23591
+ html += '</div>';
23592
+ }
23593
+ if (!v.componentFile && !v.decisions && !(feature.externalDependencies && feature.externalDependencies.length)) {
23594
+ html += '<div class="lh-empty">No developer details available for this feature yet.</div>';
23595
+ }
23596
+ }
23597
+
23598
+ if (viewName === 'product') {
23599
+ if (v.problem) {
23600
+ html += '<div class="lh-section"><div class="lh-label">Problem being solved</div>'+mdToHtml(v.problem)+'</div>';
23601
+ }
23602
+ if (v.successCriteria) {
23603
+ html += '<div class="lh-section"><div class="lh-label">Success criteria</div>'+mdToHtml(v.successCriteria)+'</div>';
23604
+ }
23605
+ if (!v.problem && !v.successCriteria) {
23606
+ html += '<div class="lh-empty">No product details available for this feature yet.</div>';
23607
+ }
23608
+ }
23609
+
23610
+ return html;
23611
+ }
23612
+
23613
+ function renderPanel(featureKey) {
23614
+ if (!_data) return;
23615
+ var feature = _data.features.find(function(f){ return f.featureKey === featureKey; });
23616
+ if (!feature) {
23617
+ document.getElementById('lh-body').innerHTML = '<div class="lh-empty">Feature "'+escHtml(featureKey)+'" not found in lac-data.json.</div>';
23618
+ return;
23619
+ }
23620
+
23621
+ document.getElementById('lh-title').textContent = feature.title || featureKey;
23622
+
23623
+ // Tabs — only show tabs that have content
23624
+ var availableViews = [];
23625
+ var viewLabels = { user: 'User', dev: 'Dev', product: 'Product' };
23626
+ ['user','dev','product'].forEach(function(v) {
23627
+ var vdata = (feature.views||{})[v];
23628
+ if (vdata && Object.keys(vdata).length > 0) availableViews.push(v);
23629
+ });
23630
+ if (!availableViews.length) availableViews = ['user'];
23631
+
23632
+ // Clamp current view to available
23633
+ if (availableViews.indexOf(_currentView) < 0) _currentView = availableViews[0];
23634
+
23635
+ var tabsEl = document.getElementById('lh-tabs');
23636
+ tabsEl.innerHTML = availableViews.map(function(v) {
23637
+ return '<button class="lh-tab'+(v===_currentView?' active':'')+'" data-view="'+v+'">'+viewLabels[v]+'</button>';
23638
+ }).join('');
23639
+ tabsEl.querySelectorAll('.lh-tab').forEach(function(btn) {
23640
+ btn.addEventListener('click', function() {
23641
+ _currentView = btn.getAttribute('data-view');
23642
+ tabsEl.querySelectorAll('.lh-tab').forEach(function(b){ b.classList.toggle('active', b===btn); });
23643
+ document.getElementById('lh-body').innerHTML =
23644
+ renderView(feature, _currentView) + renderGuideLink(featureKey);
23645
+ });
23646
+ });
23647
+
23648
+ document.getElementById('lh-body').innerHTML = renderView(feature, _currentView) + renderGuideLink(featureKey);
23649
+ }
23650
+
23651
+ function renderGuideLink(featureKey) {
23652
+ return '<a class="lh-guide-link" href="'+LAC_GUIDE_URL+'#'+escHtml(featureKey)+'" target="_blank" rel="noopener">'+
23653
+ '<span>📖</span><span>Open full guide</span><span style="font-size:10px;opacity:0.6">→</span>'+
23654
+ '</a>';
23655
+ }
23656
+
23657
+ // ── Public API ─────────────────────────────────────────────────────────────
23658
+ var LacHelp = {
23659
+ /**
23660
+ * Initialise the widget. Call once on page load.
23661
+ * @param {object} [config]
23662
+ * @param {string} [config.dataUrl] URL to lac-data.json (default: './lac-data.json')
23663
+ * @param {string} [config.defaultView] 'user' | 'dev' | 'product' (default: 'user')
23664
+ */
23665
+ init: function(config) {
23666
+ if (config) Object.assign(_config, config);
23667
+ if (_config.defaultView) _currentView = _config.defaultView;
23668
+ loadData();
23669
+ },
23670
+
23671
+ /**
23672
+ * Show the help panel for a feature.
23673
+ * @param {string} featureKey
23674
+ * @param {string} [view] 'user' | 'dev' | 'product'
23675
+ */
23676
+ show: function(featureKey, view) {
23677
+ if (view) _currentView = view;
23678
+ _currentKey = featureKey;
23679
+
23680
+ if (!_panel) buildPanel();
23681
+ _overlay.classList.add('open');
23682
+ _panel.classList.add('open');
23683
+ _panel.setAttribute('aria-hidden', 'false');
23684
+
23685
+ if (!_data) {
23686
+ loadData().then(function() { renderPanel(featureKey); });
23687
+ } else {
23688
+ renderPanel(featureKey);
23689
+ }
23690
+ },
23691
+
23692
+ /** Close the help panel. */
23693
+ hide: function() {
23694
+ if (_panel) {
23695
+ _panel.classList.remove('open');
23696
+ _panel.setAttribute('aria-hidden', 'true');
23697
+ }
23698
+ if (_overlay) _overlay.classList.remove('open');
23699
+ _currentKey = null;
23700
+ },
23701
+
23702
+ /**
23703
+ * Search features by title, domain, tags, or userGuide text.
23704
+ * @param {string} query
23705
+ * @returns {Array}
23706
+ */
23707
+ search: function(query) {
23708
+ if (!_data) return [];
23709
+ var q = query.toLowerCase();
23710
+ return _data.features.filter(function(f) {
23711
+ var hay = [f.title, f.domain, ...(f.tags||[])].join(' ').toLowerCase();
23712
+ var guide = ((f.views||{}).user||{}).userGuide||'';
23713
+ return hay.includes(q) || guide.toLowerCase().includes(q);
23714
+ });
23715
+ },
23716
+
23717
+ /** Access the raw loaded data (after init). Returns null before data loads. */
23718
+ get data() { return _data; },
23719
+ };
23720
+
23721
+ window.LacHelp = LacHelp;
23722
+
23723
+ // ── Web Component ──────────────────────────────────────────────────────────
23724
+ if (typeof customElements !== 'undefined') {
23725
+ customElements.define('lac-help', (function() {
23726
+ function LacHelpElement() {
23727
+ var el = Reflect.construct(HTMLElement, [], LacHelpElement);
23728
+ return el;
23729
+ }
23730
+ LacHelpElement.prototype = Object.create(HTMLElement.prototype);
23731
+ LacHelpElement.prototype.constructor = LacHelpElement;
23732
+ LacHelpElement.prototype.connectedCallback = function() {
23733
+ var key = this.getAttribute('feature-key');
23734
+ var view = this.getAttribute('view') || 'user';
23735
+ if (!key) return;
23736
+ this.style.cssText = 'display:inline-flex;align-items:center;';
23737
+ var btn = document.createElement('button');
23738
+ btn.textContent = '?';
23739
+ btn.title = 'Help';
23740
+ btn.setAttribute('aria-label', 'Show help');
23741
+ btn.style.cssText = [
23742
+ 'width:22px','height:22px','border-radius:50%',
23743
+ 'background:#181512','border:1px solid #c4a255',
23744
+ 'color:#c4a255','font-family:monospace','font-size:12px','font-weight:700',
23745
+ 'cursor:pointer','display:flex','align-items:center','justify-content:center',
23746
+ 'transition:background 0.15s',
23747
+ ].join(';');
23748
+ btn.addEventListener('click', function(e) {
23749
+ e.stopPropagation();
23750
+ LacHelp.show(key, view);
23751
+ });
23752
+ this.appendChild(btn);
23753
+ if (!_data && !_initPromise) loadData();
23754
+ };
23755
+ Object.defineProperty(LacHelpElement, 'observedAttributes', { get: function() { return ['feature-key','view']; } });
23756
+ return LacHelpElement;
23757
+ })());
23758
+ }
23759
+
23760
+ })();
23761
+ `;
23762
+ }
23763
+ //#endregion
23764
+ //#region src/lib/hubGenerator.ts
23765
+ /** Canonical ordered entry definitions for all standard LAC outputs. */
23766
+ const ALL_HUB_ENTRIES = [
23767
+ {
23768
+ file: "lac-guide.html",
23769
+ label: "User Guide",
23770
+ description: "How to use every user-facing feature — generated from userGuide fields",
23771
+ icon: "📖",
23772
+ primary: true
23773
+ },
23774
+ {
23775
+ file: "lac-story.html",
23776
+ label: "Product Story",
23777
+ description: "Long-form narrative case study built from feature data",
23778
+ icon: "📰",
23779
+ primary: true
23780
+ },
23781
+ {
23782
+ file: "lac-release-notes.html",
23783
+ label: "Release Notes",
23784
+ description: "User-facing release notes — features that shipped recently",
23785
+ icon: "🚀",
23786
+ primary: true
23787
+ },
23788
+ {
23789
+ file: "lac-sprint.html",
23790
+ label: "Sprint Board",
23791
+ description: "Active + draft features sorted by priority — sprint planning at a glance",
23792
+ icon: "⚡",
23793
+ primary: false
23794
+ },
23795
+ {
23796
+ file: "lac-wiki.html",
23797
+ label: "Feature Wiki",
23798
+ description: "Complete searchable wiki — all fields, all features, sidebar navigation",
23799
+ icon: "🗂️",
23800
+ primary: false
23801
+ },
23802
+ {
23803
+ file: "lac-kanban.html",
23804
+ label: "Kanban Board",
23805
+ description: "Active / Frozen / Draft columns with sortable, filterable cards",
23806
+ icon: "📋",
23807
+ primary: false
23808
+ },
23809
+ {
23810
+ file: "lac-changelog.html",
23811
+ label: "Changelog",
23812
+ description: "Feature revision history grouped by month — all changes across the workspace",
23813
+ icon: "📅",
23814
+ primary: false
23815
+ },
23816
+ {
23817
+ file: "lac-decisions.html",
23818
+ label: "Decision Log",
23819
+ description: "All architectural decisions consolidated and searchable by domain",
23820
+ icon: "⚖️",
23821
+ primary: false
23822
+ },
23823
+ {
23824
+ file: "lac-api-surface.html",
23825
+ label: "API Surface",
23826
+ description: "Aggregated public interface reference — all exported components, hooks, and types",
23827
+ icon: "🔌",
23828
+ primary: false
23829
+ },
23830
+ {
23831
+ file: "lac-depmap.html",
23832
+ label: "Dependency Map",
23833
+ description: "Runtime cross-feature dependency graph from externalDependencies[]",
23834
+ icon: "🕸️",
23835
+ primary: false
23836
+ },
23837
+ {
23838
+ file: "lac-health.html",
23839
+ label: "Health Scorecard",
23840
+ description: "Completeness, coverage, tech-debt score, and field fill rates",
23841
+ icon: "🏥",
23842
+ primary: false
23843
+ },
23844
+ {
23845
+ file: "lac-heatmap.html",
23846
+ label: "Completeness Heatmap",
23847
+ description: "Field x feature completeness grid — quickly spot gaps",
23848
+ icon: "🔥",
23849
+ primary: false
23850
+ },
23851
+ {
23852
+ file: "lac-graph.html",
23853
+ label: "Lineage Graph",
23854
+ description: "Interactive force-directed feature lineage graph",
23855
+ icon: "🌐",
23856
+ primary: false
23857
+ },
23858
+ {
23859
+ file: "lac-print.html",
23860
+ label: "Print",
23861
+ description: "Print-ready A4 document — all features in clean two-column layout",
23862
+ icon: "🖨️",
23863
+ primary: false
23864
+ },
23865
+ {
23866
+ file: "lac-raw.html",
23867
+ label: "Raw Dump",
23868
+ description: "Field-by-field dump of every feature.json with sidebar navigation",
23869
+ icon: "🔩",
23870
+ primary: false
23871
+ },
23872
+ {
23873
+ file: "lac-radar.html",
23874
+ label: "Maturity Radar",
23875
+ description: "SVG polar chart — 5 quality dimensions scored per domain",
23876
+ icon: "🎯",
23877
+ primary: false
23878
+ },
23879
+ {
23880
+ file: "lac-successboard.html",
23881
+ label: "Success Board",
23882
+ description: "successCriteria + acceptanceCriteria organized by delivery status",
23883
+ icon: "✅",
23884
+ primary: false
23885
+ },
23886
+ {
23887
+ file: "lac-pitch.html",
23888
+ label: "Pitch Deck",
23889
+ description: "Full-screen keyboard-navigable slide deck — present your product story",
23890
+ icon: "🎤",
23891
+ primary: true
23892
+ },
23893
+ {
23894
+ file: "lac-timeline.html",
23895
+ label: "Feature Timeline",
23896
+ description: "Horizontal swim-lane timeline built from statusHistory — velocity at a glance",
23897
+ icon: "📆",
23898
+ primary: false
23899
+ },
23900
+ {
23901
+ file: "lac-data.json",
23902
+ label: "Data Export",
23903
+ description: "Universal JSON bridge — all features with multi-view projections for in-app help and docs",
23904
+ icon: "📦",
23905
+ primary: false
23906
+ },
23907
+ {
23908
+ file: "lac-help.js",
23909
+ label: "Help Widget",
23910
+ description: "Zero-dep vanilla JS widget + Web Component — drop in any app for contextual in-app help",
23911
+ icon: "💡",
23912
+ primary: false
23913
+ }
23914
+ ];
23031
23915
  function generateHub(projectName, stats, entries, generatedAt = (/* @__PURE__ */ new Date()).toISOString(), prefix) {
23032
23916
  function esc(s) {
23033
23917
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -23145,325 +24029,1634 @@ body { background: var(--bg); color: var(--text); font-family: var(--sans); font
23145
24029
  border-radius: 10px; text-decoration: none;
23146
24030
  transition: border-color 0.15s, background 0.15s;
23147
24031
  }
23148
- .secondary-card:hover { background: var(--bg-hover); border-color: var(--text-soft); }
23149
- .secondary-card-icon { font-size: 20px; margin-bottom: 8px; display: block; }
23150
- .secondary-card-label { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
23151
- .secondary-card-desc { font-size: 12px; color: var(--text-soft); line-height: 1.55; }
24032
+ .secondary-card:hover { background: var(--bg-hover); border-color: var(--text-soft); }
24033
+ .secondary-card-icon { font-size: 20px; margin-bottom: 8px; display: block; }
24034
+ .secondary-card-label { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
24035
+ .secondary-card-desc { font-size: 12px; color: var(--text-soft); line-height: 1.55; }
24036
+
24037
+ /* ── Footer ── */
24038
+ .footer { margin-top: 64px; padding-top: 24px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
24039
+ .footer-brand { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
24040
+ .footer-sep { font-family: var(--mono); font-size: 10px; color: var(--border); }
24041
+ .footer-note { font-size: 11px; color: var(--text-soft); }
24042
+ </style>
24043
+ ${urlPrefix ? "" : `<script>
24044
+ function lacGo(file){location.href=location.href.replace(/[^\\/]*$/,'')+file}
24045
+ <\/script>`}
24046
+ </head>
24047
+ <body>
24048
+
24049
+ <div class="topbar">
24050
+ <span class="topbar-brand">lac·hub</span>
24051
+ <span class="topbar-sep">/</span>
24052
+ <span class="topbar-title">${esc(projectName)}</span>
24053
+ <span class="topbar-date">Generated ${esc(date)}</span>
24054
+ </div>
24055
+
24056
+ <div class="page">
24057
+
24058
+ <div class="hero">
24059
+ <div class="hero-eyebrow">life-as-code</div>
24060
+ <div class="hero-title">${esc(projectName)}</div>
24061
+ <div class="hero-sub">Feature documentation hub — every view of the project's feature.json data in one place.</div>
24062
+ <div class="stats-row">
24063
+ <div class="stat stat-total">
24064
+ <div class="stat-num">${stats.total}</div>
24065
+ <div class="stat-lbl">features</div>
24066
+ </div>
24067
+ <div class="stat stat-frozen">
24068
+ <div class="stat-num">${stats.frozen}</div>
24069
+ <div class="stat-lbl">frozen</div>
24070
+ </div>
24071
+ <div class="stat stat-active">
24072
+ <div class="stat-num">${stats.active}</div>
24073
+ <div class="stat-lbl">active</div>
24074
+ </div>
24075
+ <div class="stat stat-draft">
24076
+ <div class="stat-num">${stats.draft}</div>
24077
+ <div class="stat-lbl">draft</div>
24078
+ </div>
24079
+ <div class="stat">
24080
+ <div class="stat-num" style="color:var(--text-mid)">${stats.domains.length}</div>
24081
+ <div class="stat-lbl">domains</div>
24082
+ </div>
24083
+ </div>
24084
+ ${domainList ? `<div class="domains-row">${domainList}</div>` : ""}
24085
+ </div>
24086
+
24087
+ ${primaryEntries.length > 0 ? `
24088
+ <div class="section-header">
24089
+ <div class="section-title">User-facing</div>
24090
+ <div class="section-rule"></div>
24091
+ </div>
24092
+ <div class="primary-grid">
24093
+ ${primaryEntries.map(primaryCard).join("")}
24094
+ </div>` : ""}
24095
+
24096
+ ${secondaryEntries.length > 0 ? `
24097
+ <div class="section-header">
24098
+ <div class="section-title">Developer views</div>
24099
+ <div class="section-rule"></div>
24100
+ </div>
24101
+ <div class="secondary-grid">
24102
+ ${secondaryEntries.map(secondaryCard).join("")}
24103
+ </div>` : ""}
24104
+
24105
+ <div class="footer">
24106
+ <span class="footer-brand">@majeanson/lac</span>
24107
+ <span class="footer-sep">//</span>
24108
+ <span class="footer-note">Generated from feature.json files. Run <code style="font-family:var(--mono);font-size:10px;background:#1e1a16;padding:1px 5px;border-radius:3px">lac export --all</code> to regenerate.</span>
24109
+ </div>
24110
+
24111
+ </div>
24112
+ </body>
24113
+ </html>`;
24114
+ }
24115
+ //#endregion
24116
+ //#region src/lib/radarGenerator.ts
24117
+ function hasText$1(v, min = 10) {
24118
+ return typeof v === "string" && v.trim().length >= min;
24119
+ }
24120
+ function arrLen(v) {
24121
+ return Array.isArray(v) ? v.length : 0;
24122
+ }
24123
+ function esc$4(s) {
24124
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24125
+ }
24126
+ const METRICS = [
24127
+ {
24128
+ key: "docs",
24129
+ label: "Documentation",
24130
+ color: "#5b82cc",
24131
+ desc: "Avg fill of problem, analysis, implementation",
24132
+ score(fs) {
24133
+ if (!fs.length) return 0;
24134
+ return fs.reduce((s, f) => {
24135
+ let n = 0;
24136
+ if (hasText$1(f["problem"])) n++;
24137
+ if (hasText$1(f["analysis"])) n++;
24138
+ if (hasText$1(f["implementation"])) n++;
24139
+ return s + n / 3;
24140
+ }, 0) / fs.length;
24141
+ }
24142
+ },
24143
+ {
24144
+ key: "decisions",
24145
+ label: "Decision Quality",
24146
+ color: "#9b7ecc",
24147
+ desc: "% features with 2+ documented decisions",
24148
+ score(fs) {
24149
+ if (!fs.length) return 0;
24150
+ return fs.filter((f) => arrLen(f["decisions"]) >= 2).length / fs.length;
24151
+ }
24152
+ },
24153
+ {
24154
+ key: "guide",
24155
+ label: "User Guide",
24156
+ color: "#4aad72",
24157
+ desc: "% features with userGuide written",
24158
+ score(fs) {
24159
+ if (!fs.length) return 0;
24160
+ return fs.filter((f) => hasText$1(f["userGuide"], 1)).length / fs.length;
24161
+ }
24162
+ },
24163
+ {
24164
+ key: "code",
24165
+ label: "Code Reference",
24166
+ color: "#d4a853",
24167
+ desc: "% features with componentFile linked",
24168
+ score(fs) {
24169
+ if (!fs.length) return 0;
24170
+ return fs.filter((f) => hasText$1(f["componentFile"], 1)).length / fs.length;
24171
+ }
24172
+ },
24173
+ {
24174
+ key: "shipped",
24175
+ label: "Ship Rate",
24176
+ color: "#e07b54",
24177
+ desc: "% features frozen / shipped",
24178
+ score(fs) {
24179
+ if (!fs.length) return 0;
24180
+ return fs.filter((f) => f["status"] === "frozen").length / fs.length;
24181
+ }
24182
+ }
24183
+ ];
24184
+ function generateRadar(features, projectName) {
24185
+ const domains = [...new Set(features.map((f) => f["domain"] || "misc"))].sort();
24186
+ const byDomain = /* @__PURE__ */ new Map();
24187
+ for (const f of features) {
24188
+ const d = f["domain"] || "misc";
24189
+ if (!byDomain.has(d)) byDomain.set(d, []);
24190
+ byDomain.get(d).push(f);
24191
+ }
24192
+ const N = domains.length;
24193
+ const CX = 240, CY = 220, R = 170;
24194
+ const PAD_LABEL = 28;
24195
+ const scores = METRICS.map((m) => domains.map((d) => m.score(byDomain.get(d) ?? [])));
24196
+ function polar(i, r) {
24197
+ const angle = i / N * 2 * Math.PI - Math.PI / 2;
24198
+ return {
24199
+ x: CX + r * Math.cos(angle),
24200
+ y: CY + r * Math.sin(angle)
24201
+ };
24202
+ }
24203
+ function polygonPts(scoreRow) {
24204
+ return scoreRow.map((s, i) => {
24205
+ const p = polar(i, s * R);
24206
+ return `${p.x.toFixed(1)},${p.y.toFixed(1)}`;
24207
+ }).join(" ");
24208
+ }
24209
+ const rings = [
24210
+ .25,
24211
+ .5,
24212
+ .75,
24213
+ 1
24214
+ ].map((pct) => {
24215
+ return `<polygon points="${domains.map((_, i) => {
24216
+ const p = polar(i, pct * R);
24217
+ return `${p.x.toFixed(1)},${p.y.toFixed(1)}`;
24218
+ }).join(" ")}" fill="none" stroke="#2a2724" stroke-width="${pct === 1 ? 1.5 : .8}"/>`;
24219
+ }).join("\n");
24220
+ const spokes = domains.map((_, i) => {
24221
+ const p = polar(i, R);
24222
+ return `<line x1="${CX}" y1="${CY}" x2="${p.x.toFixed(1)}" y2="${p.y.toFixed(1)}" stroke="#2a2724" stroke-width="0.8"/>`;
24223
+ }).join("\n");
24224
+ const labels = domains.map((d, i) => {
24225
+ const p = polar(i, R + PAD_LABEL);
24226
+ const anchor = p.x < CX - 5 ? "end" : p.x > CX + 5 ? "start" : "middle";
24227
+ const label = d.replace(/-/g, "‑");
24228
+ return `<text x="${p.x.toFixed(1)}" y="${(p.y + 4).toFixed(1)}" text-anchor="${anchor}" class="domain-label">${esc$4(label)}</text>`;
24229
+ }).join("\n");
24230
+ const metricPolygons = METRICS.map((m, mi) => {
24231
+ const pts = polygonPts(scores[mi]);
24232
+ return `<polygon id="poly-${m.key}" points="${pts}" fill="${m.color}" fill-opacity="0.12" stroke="${m.color}" stroke-width="1.8" stroke-linejoin="round" class="metric-poly" data-metric="${m.key}"/>`;
24233
+ }).join("\n");
24234
+ const ringLabels = [
24235
+ 25,
24236
+ 50,
24237
+ 75,
24238
+ 100
24239
+ ].map((pct) => {
24240
+ const p = polar(0, pct / 100 * R);
24241
+ return `<text x="${(CX - 6).toFixed(1)}" y="${(p.y + 3).toFixed(1)}" text-anchor="end" class="ring-label">${pct}%</text>`;
24242
+ }).join("\n");
24243
+ const tableRows = domains.map((d, di) => {
24244
+ const domFeats = byDomain.get(d) ?? [];
24245
+ const cells = METRICS.map((m, mi) => {
24246
+ const pct = Math.round(scores[mi][di] * 100);
24247
+ return `<td style="color:${pct >= 70 ? "#4aad72" : pct >= 40 ? "#c4a255" : "#cc5b5b"};font-family:var(--mono);text-align:center">${pct}%</td>`;
24248
+ });
24249
+ return `<tr>
24250
+ <td><strong>${esc$4(d)}</strong></td>
24251
+ <td style="color:var(--text-soft);text-align:center">${domFeats.length}</td>
24252
+ ${cells.join("")}
24253
+ </tr>`;
24254
+ }).join("\n");
24255
+ const compositeByDomain = domains.map((_, di) => Math.round(METRICS.reduce((s, _, mi) => s + scores[mi][di], 0) / METRICS.length * 100));
24256
+ const dataJson = JSON.stringify(domains.map((d, di) => ({
24257
+ domain: d,
24258
+ count: (byDomain.get(d) ?? []).length,
24259
+ composite: compositeByDomain[di],
24260
+ scores: Object.fromEntries(METRICS.map((m, mi) => [m.key, Math.round(scores[mi][di] * 100)]))
24261
+ }))).replace(/<\/script>/gi, "<\\/script>");
24262
+ const svgWidth = 480, svgHeight = 440;
24263
+ return `<!DOCTYPE html>
24264
+ <html lang="en">
24265
+ <head>
24266
+ <meta charset="UTF-8">
24267
+ <meta name="viewport" content="width=device-width,initial-scale=1">
24268
+ <title>${esc$4(projectName)} — Domain Maturity Radar</title>
24269
+ <style>
24270
+ :root {
24271
+ --bg: #12100e;
24272
+ --bg-card: #1a1714;
24273
+ --bg-hover: #201d1a;
24274
+ --border: #2a2724;
24275
+ --border-soft: #221f1c;
24276
+ --text: #e8e0d4;
24277
+ --text-soft: #8a7f74;
24278
+ --accent: #d4a853;
24279
+ --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
24280
+ }
24281
+ * { box-sizing: border-box; margin: 0; padding: 0; }
24282
+ html, body { height: 100%; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, sans-serif; }
24283
+ body { display: flex; flex-direction: column; min-height: 100vh; }
24284
+
24285
+ .topbar {
24286
+ display: flex; align-items: center; gap: 10px; padding: 0 20px; height: 48px;
24287
+ background: #0e0c0a; border-bottom: 1px solid var(--border); flex-shrink: 0;
24288
+ font-size: 13px;
24289
+ }
24290
+ .topbar-logo { color: var(--accent); font-weight: 700; font-family: var(--mono); }
24291
+ .topbar-sep { color: var(--border); }
24292
+ .topbar-project { color: var(--text); }
24293
+ .topbar-count { margin-left: auto; color: var(--text-soft); font-size: 12px; font-family: var(--mono); }
24294
+
24295
+ .main { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 32px 24px; gap: 32px; }
24296
+
24297
+ h1 { font-size: 20px; font-weight: 600; color: var(--text); }
24298
+ .subtitle { font-size: 13px; color: var(--text-soft); margin-top: 4px; text-align: center; }
24299
+
24300
+ .radar-wrap {
24301
+ display: flex; gap: 40px; align-items: flex-start; flex-wrap: wrap; justify-content: center;
24302
+ }
24303
+
24304
+ svg.radar { overflow: visible; }
24305
+ .domain-label { font-size: 11px; fill: var(--text); font-family: var(--mono); }
24306
+ .ring-label { font-size: 9px; fill: var(--text-soft); font-family: var(--mono); }
24307
+ .metric-poly { cursor: pointer; transition: fill-opacity 0.2s, stroke-width 0.2s; }
24308
+ .metric-poly:hover { fill-opacity: 0.35; stroke-width: 2.5; }
24309
+ .metric-poly.dimmed { fill-opacity: 0.04; stroke-opacity: 0.25; }
24310
+
24311
+ .legend {
24312
+ display: flex; flex-direction: column; gap: 10px; padding: 20px;
24313
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
24314
+ min-width: 200px;
24315
+ }
24316
+ .legend-title { font-size: 11px; color: var(--text-soft); font-family: var(--mono); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 4px; }
24317
+ .legend-item {
24318
+ display: flex; align-items: flex-start; gap: 10px; cursor: pointer;
24319
+ padding: 6px 8px; border-radius: 4px; transition: background 0.15s;
24320
+ }
24321
+ .legend-item:hover { background: var(--bg-hover); }
24322
+ .legend-item.dimmed { opacity: 0.35; }
24323
+ .legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; margin-top: 2px; }
24324
+ .legend-text { display: flex; flex-direction: column; }
24325
+ .legend-label { font-size: 13px; font-weight: 500; color: var(--text); }
24326
+ .legend-desc { font-size: 11px; color: var(--text-soft); margin-top: 1px; }
24327
+
24328
+ .table-wrap {
24329
+ width: 100%; max-width: 760px;
24330
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
24331
+ overflow: hidden;
24332
+ }
24333
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
24334
+ th {
24335
+ padding: 10px 14px; text-align: left; font-weight: 500; font-size: 11px;
24336
+ color: var(--text-soft); border-bottom: 1px solid var(--border);
24337
+ text-transform: uppercase; letter-spacing: .06em; font-family: var(--mono);
24338
+ }
24339
+ th.metric-header { cursor: pointer; }
24340
+ th.metric-header:hover { color: var(--text); }
24341
+ td { padding: 10px 14px; border-bottom: 1px solid var(--border-soft); }
24342
+ tr:last-child td { border-bottom: none; }
24343
+ tr:hover td { background: var(--bg-hover); }
24344
+
24345
+ .tooltip {
24346
+ position: fixed; pointer-events: none; z-index: 999;
24347
+ background: #1e1b18; border: 1px solid var(--border); border-radius: 6px;
24348
+ padding: 10px 14px; font-size: 12px; color: var(--text); max-width: 220px;
24349
+ display: none; box-shadow: 0 8px 24px rgba(0,0,0,.5);
24350
+ }
24351
+ .tooltip.visible { display: block; }
24352
+ .tooltip-domain { font-weight: 600; margin-bottom: 6px; color: var(--accent); font-family: var(--mono); font-size: 11px; }
24353
+ .tooltip-row { display: flex; justify-content: space-between; gap: 16px; margin: 2px 0; }
24354
+ .tooltip-label { color: var(--text-soft); }
24355
+ .tooltip-val { font-family: var(--mono); }
24356
+ </style>
24357
+ </head>
24358
+ <body>
24359
+ <div class="topbar">
24360
+ <span class="topbar-logo">◈ lac</span>
24361
+ <span class="topbar-sep">|</span>
24362
+ <span class="topbar-project">${esc$4(projectName)}</span>
24363
+ <span class="topbar-count">${features.length} features · ${domains.length} domains · Domain Maturity Radar</span>
24364
+ </div>
24365
+
24366
+ <div class="main">
24367
+ <div style="text-align:center">
24368
+ <h1>Domain Maturity Radar</h1>
24369
+ <p class="subtitle">5 quality dimensions scored per domain — hover a legend item to isolate a metric</p>
24370
+ </div>
24371
+
24372
+ <div class="radar-wrap">
24373
+ <svg class="radar" width="${svgWidth}" height="${svgHeight}" viewBox="0 0 ${svgWidth} ${svgHeight}">
24374
+ <!-- Guide rings -->
24375
+ ${rings}
24376
+ <!-- Spokes -->
24377
+ ${spokes}
24378
+ <!-- Ring labels -->
24379
+ ${ringLabels}
24380
+ <!-- Metric polygons -->
24381
+ ${metricPolygons}
24382
+ <!-- Domain labels -->
24383
+ ${labels}
24384
+ <!-- Center dot -->
24385
+ <circle cx="${CX}" cy="${CY}" r="3" fill="var(--border)"/>
24386
+ </svg>
24387
+
24388
+ <div class="legend">
24389
+ <div class="legend-title">Metric</div>
24390
+ ${METRICS.map((m) => `
24391
+ <div class="legend-item" data-metric="${m.key}" onclick="toggleMetric('${m.key}')">
24392
+ <span class="legend-dot" style="background:${m.color}"></span>
24393
+ <span class="legend-text">
24394
+ <span class="legend-label">${esc$4(m.label)}</span>
24395
+ <span class="legend-desc">${esc$4(m.desc)}</span>
24396
+ </span>
24397
+ </div>`).join("")}
24398
+ </div>
24399
+ </div>
24400
+
24401
+ <div class="table-wrap">
24402
+ <table>
24403
+ <thead>
24404
+ <tr>
24405
+ <th>Domain</th>
24406
+ <th style="text-align:center">Features</th>
24407
+ ${METRICS.map((m) => `<th class="metric-header" style="text-align:center;color:${m.color}" title="${esc$4(m.desc)}">${esc$4(m.label)}</th>`).join("")}
24408
+ </tr>
24409
+ </thead>
24410
+ <tbody id="table-body">
24411
+ ${tableRows}
24412
+ </tbody>
24413
+ </table>
24414
+ </div>
24415
+ </div>
24416
+
24417
+ <div class="tooltip" id="tooltip"></div>
24418
+
24419
+ <script>
24420
+ const DATA = ${dataJson};
24421
+ const byDomain = new Map(DATA.map(d => [d.domain, d]));
24422
+ const METRIC_COLORS = {${METRICS.map((m) => `'${m.key}': '${m.color}'`).join(", ")}};
24423
+
24424
+ let activeMetrics = new Set(${JSON.stringify(METRICS.map((m) => m.key))});
24425
+
24426
+ function toggleMetric(key) {
24427
+ if (activeMetrics.size === ${METRICS.length} && activeMetrics.has(key)) {
24428
+ // Solo this metric
24429
+ activeMetrics.clear();
24430
+ activeMetrics.add(key);
24431
+ } else if (activeMetrics.size === 1 && activeMetrics.has(key)) {
24432
+ // Re-activate all
24433
+ ${JSON.stringify(METRICS.map((m) => m.key))}.forEach(k => activeMetrics.add(k));
24434
+ } else {
24435
+ if (activeMetrics.has(key)) activeMetrics.delete(key);
24436
+ else activeMetrics.add(key);
24437
+ }
24438
+ updateVisibility();
24439
+ }
24440
+
24441
+ function updateVisibility() {
24442
+ document.querySelectorAll('.metric-poly').forEach(el => {
24443
+ const k = el.dataset.metric;
24444
+ el.classList.toggle('dimmed', !activeMetrics.has(k));
24445
+ });
24446
+ document.querySelectorAll('.legend-item').forEach(el => {
24447
+ const k = el.dataset.metric;
24448
+ el.classList.toggle('dimmed', !activeMetrics.has(k));
24449
+ });
24450
+ }
24451
+
24452
+ // Domain hover tooltip via SVG polygon hit-test approximation
24453
+ // Attach mousemove to SVG, find nearest domain spoke
24454
+ const svg = document.querySelector('svg.radar');
24455
+ const tooltip = document.getElementById('tooltip');
24456
+ const CX = ${CX}, CY = ${CY};
24457
+ const domainAngles = ${JSON.stringify(domains.map((_, i) => i / N * 360 - 90))};
24458
+ const domainNames = ${JSON.stringify(domains)};
24459
+
24460
+ svg.addEventListener('mousemove', e => {
24461
+ const rect = svg.getBoundingClientRect();
24462
+ const x = (e.clientX - rect.left) * (${svgWidth} / rect.width) - CX;
24463
+ const y = (e.clientY - rect.top) * (${svgHeight} / rect.height) - CY;
24464
+ const dist = Math.sqrt(x*x + y*y);
24465
+ if (dist < 15 || dist > ${R + 40}) { tooltip.classList.remove('visible'); return; }
24466
+
24467
+ let angle = Math.atan2(y, x) * 180 / Math.PI + 90;
24468
+ if (angle < 0) angle += 360;
24469
+
24470
+ // Find nearest domain spoke
24471
+ const step = 360 / domainNames.length;
24472
+ const idx = Math.round(angle / step) % domainNames.length;
24473
+ const domain = domainNames[idx];
24474
+ const d = byDomain.get(domain);
24475
+ if (!d) { tooltip.classList.remove('visible'); return; }
24476
+
24477
+ const metricsHtml = Object.entries(d.scores).map(([k, v]) => {
24478
+ const color = v >= 70 ? '#4aad72' : v >= 40 ? '#c4a255' : '#cc5b5b';
24479
+ return '<div class="tooltip-row"><span class="tooltip-label">' + k + '</span><span class="tooltip-val" style="color:' + color + '">' + v + '%</span></div>';
24480
+ }).join('');
24481
+
24482
+ tooltip.innerHTML = '<div class="tooltip-domain">' + domain + '</div>' +
24483
+ '<div class="tooltip-row"><span class="tooltip-label">features</span><span class="tooltip-val">' + d.count + '</span></div>' +
24484
+ metricsHtml;
24485
+ tooltip.style.left = (e.clientX + 14) + 'px';
24486
+ tooltip.style.top = (e.clientY - 10) + 'px';
24487
+ tooltip.classList.add('visible');
24488
+ });
24489
+
24490
+ svg.addEventListener('mouseleave', () => tooltip.classList.remove('visible'));
24491
+ <\/script>
24492
+ </body>
24493
+ </html>`;
24494
+ }
24495
+ //#endregion
24496
+ //#region src/lib/successboardGenerator.ts
24497
+ function esc$3(s) {
24498
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24499
+ }
24500
+ function first(s, chars = 160) {
24501
+ const str = typeof s === "string" ? s.trim() : "";
24502
+ return str.length > chars ? str.slice(0, chars - 1) + "…" : str;
24503
+ }
24504
+ const STATUS_COLOR$2 = {
24505
+ frozen: "#5b82cc",
24506
+ active: "#4aad72",
24507
+ draft: "#c4a255",
24508
+ deprecated: "#664444"
24509
+ };
24510
+ const STATUS_LABEL = {
24511
+ frozen: "🔒 Achieved",
24512
+ active: "🟡 In Progress",
24513
+ draft: "⚪ Planned",
24514
+ deprecated: "❌ Deprecated"
24515
+ };
24516
+ const COLUMN_ORDER = [
24517
+ "frozen",
24518
+ "active",
24519
+ "draft"
24520
+ ];
24521
+ function generateSuccessboard(features, projectName) {
24522
+ const hasCriteria = (f) => typeof f["successCriteria"] === "string" && f["successCriteria"].trim().length > 0 || Array.isArray(f["acceptanceCriteria"]) && f["acceptanceCriteria"].length > 0;
24523
+ const withCriteria = features.filter(hasCriteria);
24524
+ const withoutCriteria = features.filter((f) => !hasCriteria(f));
24525
+ const domains = [...new Set(features.map((f) => f["domain"] || "misc"))].sort();
24526
+ const byStatus = /* @__PURE__ */ new Map();
24527
+ for (const status of COLUMN_ORDER) byStatus.set(status, []);
24528
+ for (const f of withCriteria) {
24529
+ const s = f["status"] || "draft";
24530
+ if (!byStatus.has(s)) byStatus.set(s, []);
24531
+ byStatus.get(s).push(f);
24532
+ }
24533
+ const frozenCount = features.filter((f) => f["status"] === "frozen").length;
24534
+ const criteriaPct = features.length ? Math.round(withCriteria.length / features.length * 100) : 0;
24535
+ const frozenWithCriteria = withCriteria.filter((f) => f["status"] === "frozen").length;
24536
+ function renderAC(f) {
24537
+ const ac = f["acceptanceCriteria"];
24538
+ if (!Array.isArray(ac) || ac.length === 0) return "";
24539
+ const isFrozen = f["status"] === "frozen";
24540
+ return `<ul class="ac-list">` + ac.map((item) => {
24541
+ return `<li class="ac-item${isFrozen ? " ac-done" : ""}">
24542
+ <span class="ac-check">${isFrozen ? "✓" : "○"}</span>
24543
+ <span class="ac-text">${esc$3(typeof item === "string" ? item : String(item))}</span>
24544
+ </li>`;
24545
+ }).join("") + `</ul>`;
24546
+ }
24547
+ function renderCard(f) {
24548
+ const status = f["status"] || "draft";
24549
+ const sc = typeof f["successCriteria"] === "string" ? f["successCriteria"].trim() : "";
24550
+ const domain = f["domain"] || "";
24551
+ const priority = f["priority"] != null ? `P${f["priority"]}` : "";
24552
+ const acHtml = renderAC(f);
24553
+ const key = f["featureKey"] || "";
24554
+ return `<div class="card" data-domain="${esc$3(domain)}" data-status="${esc$3(status)}" onclick="window.open('lac-wiki.html#${esc$3(key)}','_self')">
24555
+ <div class="card-header">
24556
+ ${domain ? `<span class="badge badge-domain">${esc$3(domain)}</span>` : ""}
24557
+ ${priority ? `<span class="badge badge-priority">${esc$3(priority)}</span>` : ""}
24558
+ </div>
24559
+ <div class="card-title">${esc$3(first(f["title"], 80))}</div>
24560
+ ${sc ? `<blockquote class="card-sc">${esc$3(first(sc, 200))}</blockquote>` : ""}
24561
+ ${acHtml}
24562
+ <div class="card-key">${esc$3(key)}</div>
24563
+ </div>`;
24564
+ }
24565
+ const columnHtml = COLUMN_ORDER.map((status) => {
24566
+ const colFeatures = (byStatus.get(status) ?? []).sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
24567
+ return `<div class="column">
24568
+ <div class="col-header" style="border-left:3px solid ${STATUS_COLOR$2[status] ?? "#888"}">
24569
+ <span class="col-title">${STATUS_LABEL[status] ?? status}</span>
24570
+ <span class="col-count">${colFeatures.length}</span>
24571
+ </div>
24572
+ <div class="col-cards" id="col-${status}">
24573
+ ${colFeatures.length === 0 ? `<div class="col-empty">No features yet</div>` : colFeatures.map(renderCard).join("\n")}
24574
+ </div>
24575
+ </div>`;
24576
+ }).join("\n");
24577
+ const domainPills = domains.map((d) => {
24578
+ const count = withCriteria.filter((f) => f["domain"] === d).length;
24579
+ return `<span class="domain-pill" data-domain="${esc$3(d)}" onclick="toggleDomain('${esc$3(d)}')">${esc$3(d)} <span class="pill-count">${count}</span></span>`;
24580
+ }).join("");
24581
+ const missingCards = withoutCriteria.slice(0, 12).map((f) => {
24582
+ const status = f["status"] || "draft";
24583
+ return `<div class="missing-card" onclick="window.open('lac-wiki.html#${esc$3(f["featureKey"] || "")}','_self')">
24584
+ <span class="missing-status" style="color:${STATUS_COLOR$2[status] ?? "#888"}">●</span>
24585
+ <span class="missing-title">${esc$3(first(f["title"], 60))}</span>
24586
+ <span class="missing-domain">${esc$3(f["domain"] || "")}</span>
24587
+ <span class="missing-hint">+ add successCriteria</span>
24588
+ </div>`;
24589
+ }).join("");
24590
+ const dataJson = JSON.stringify({
24591
+ total: features.length,
24592
+ withCriteria: withCriteria.length,
24593
+ without: withoutCriteria.length,
24594
+ frozenWithCriteria,
24595
+ criteriaPct
24596
+ }).replace(/<\/script>/gi, "<\\/script>");
24597
+ return `<!DOCTYPE html>
24598
+ <html lang="en">
24599
+ <head>
24600
+ <meta charset="UTF-8">
24601
+ <meta name="viewport" content="width=device-width,initial-scale=1">
24602
+ <title>${esc$3(projectName)} — Success Board</title>
24603
+ <style>
24604
+ :root {
24605
+ --bg: #12100e; --bg-card: #1a1714; --bg-hover: #201d1a;
24606
+ --border: #2a2724; --border-soft: #221f1c;
24607
+ --text: #e8e0d4; --text-soft: #8a7f74; --accent: #d4a853;
24608
+ --mono: 'SF Mono','Fira Code','Cascadia Code',monospace;
24609
+ }
24610
+ * { box-sizing: border-box; margin: 0; padding: 0; }
24611
+ html, body { height: 100%; background: var(--bg); color: var(--text); font-family: system-ui,-apple-system,sans-serif; }
24612
+
24613
+ .topbar {
24614
+ display: flex; align-items: center; gap: 10px; padding: 0 20px; height: 48px;
24615
+ background: #0e0c0a; border-bottom: 1px solid var(--border); flex-shrink: 0; font-size: 13px;
24616
+ }
24617
+ .topbar-logo { color: var(--accent); font-weight: 700; font-family: var(--mono); }
24618
+ .topbar-sep { color: var(--border); }
24619
+ .topbar-count { margin-left: auto; color: var(--text-soft); font-size: 12px; font-family: var(--mono); }
24620
+
24621
+ .hero {
24622
+ padding: 28px 24px 0;
24623
+ display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 20px;
24624
+ }
24625
+ .hero-text h1 { font-size: 22px; font-weight: 600; }
24626
+ .hero-text p { font-size: 13px; color: var(--text-soft); margin-top: 4px; }
24627
+
24628
+ .stats-row { display: flex; gap: 16px; flex-wrap: wrap; }
24629
+ .stat-card {
24630
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
24631
+ padding: 14px 18px; text-align: center; min-width: 90px;
24632
+ }
24633
+ .stat-num { font-size: 28px; font-weight: 700; font-family: var(--mono); color: var(--accent); }
24634
+ .stat-label { font-size: 11px; color: var(--text-soft); margin-top: 2px; }
24635
+
24636
+ .progress-bar-wrap { padding: 16px 24px 0; }
24637
+ .progress-bar-label { font-size: 12px; color: var(--text-soft); margin-bottom: 6px; font-family: var(--mono); }
24638
+ .progress-bar { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
24639
+ .progress-fill { height: 100%; background: linear-gradient(90deg, #5b82cc, #4aad72); border-radius: 3px; transition: width .6s ease; }
24640
+
24641
+ .filters { padding: 16px 24px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--border); }
24642
+ .filter-label { font-size: 11px; color: var(--text-soft); font-family: var(--mono); }
24643
+ .domain-pill {
24644
+ font-size: 11px; padding: 4px 10px; border-radius: 12px; cursor: pointer;
24645
+ background: var(--bg-card); border: 1px solid var(--border); color: var(--text-soft);
24646
+ transition: all .15s; user-select: none;
24647
+ }
24648
+ .domain-pill:hover { border-color: var(--accent); color: var(--text); }
24649
+ .domain-pill.active { background: var(--accent); color: #12100e; border-color: var(--accent); font-weight: 600; }
24650
+ .pill-count { opacity: .6; }
24651
+
24652
+ .board { display: flex; gap: 0; flex: 1; overflow: hidden; height: calc(100vh - 260px); min-height: 400px; }
24653
+ .column { flex: 1; display: flex; flex-direction: column; border-right: 1px solid var(--border); min-width: 260px; }
24654
+ .column:last-child { border-right: none; }
24655
+ .col-header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; background: var(--bg-card); border-bottom: 1px solid var(--border); flex-shrink: 0; margin: 0; padding-left: 13px; }
24656
+ .col-title { font-size: 13px; font-weight: 600; }
24657
+ .col-count { font-size: 12px; color: var(--text-soft); background: var(--border); border-radius: 10px; padding: 1px 8px; font-family: var(--mono); }
24658
+ .col-cards { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
24659
+ .col-empty { padding: 24px 0; text-align: center; color: var(--text-soft); font-size: 13px; font-style: italic; }
24660
+
24661
+ .card {
24662
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
24663
+ padding: 14px 14px 12px; cursor: pointer; transition: border-color .15s, background .15s;
24664
+ }
24665
+ .card:hover { border-color: var(--accent); background: var(--bg-hover); }
24666
+ .card-header { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
24667
+ .badge { font-size: 10px; padding: 2px 8px; border-radius: 10px; font-family: var(--mono); }
24668
+ .badge-domain { background: #2a2724; color: var(--text-soft); border: 1px solid var(--border); }
24669
+ .badge-priority { background: #1e1b12; color: var(--accent); border: 1px solid #3a2f10; }
24670
+ .card-title { font-size: 14px; font-weight: 600; line-height: 1.35; margin-bottom: 8px; }
24671
+ .card-sc {
24672
+ font-size: 12px; color: var(--text-soft); font-style: italic; line-height: 1.5;
24673
+ border-left: 2px solid var(--accent); padding-left: 8px; margin: 6px 0 8px;
24674
+ }
24675
+ .ac-list { list-style: none; display: flex; flex-direction: column; gap: 4px; margin: 6px 0; }
24676
+ .ac-item { display: flex; align-items: flex-start; gap: 6px; font-size: 12px; }
24677
+ .ac-check { color: var(--text-soft); flex-shrink: 0; font-size: 11px; margin-top: 1px; font-family: var(--mono); }
24678
+ .ac-done .ac-check { color: #4aad72; }
24679
+ .ac-text { color: var(--text-soft); line-height: 1.4; }
24680
+ .ac-done .ac-text { color: var(--text); text-decoration: line-through; opacity: .6; }
24681
+ .card-key { font-size: 10px; font-family: var(--mono); color: #4a4540; margin-top: 8px; }
24682
+
24683
+ .missing-section { padding: 20px 24px; border-top: 1px solid var(--border); }
24684
+ .missing-title { font-size: 13px; font-weight: 600; color: var(--text-soft); margin-bottom: 12px; }
24685
+ .missing-grid { display: flex; flex-direction: column; gap: 6px; }
24686
+ .missing-card {
24687
+ display: flex; align-items: center; gap: 10px; padding: 8px 12px;
24688
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
24689
+ cursor: pointer; font-size: 12px; transition: border-color .15s;
24690
+ }
24691
+ .missing-card:hover { border-color: var(--accent); }
24692
+ .missing-status { flex-shrink: 0; }
24693
+ .missing-title-text, .missing-title { flex: 1; }
24694
+ .missing-domain { color: var(--text-soft); font-family: var(--mono); font-size: 10px; }
24695
+ .missing-hint { color: #4aad72; font-size: 10px; opacity: .6; }
24696
+ </style>
24697
+ </head>
24698
+ <body>
24699
+ <div class="topbar">
24700
+ <span class="topbar-logo">◈ lac</span>
24701
+ <span class="topbar-sep">|</span>
24702
+ <span class="topbar-project">${esc$3(projectName)} — Success Board</span>
24703
+ <span class="topbar-count">${withCriteria.length}/${features.length} features have criteria</span>
24704
+ </div>
24705
+
24706
+ <div class="hero">
24707
+ <div class="hero-text">
24708
+ <h1>What does "done" look like?</h1>
24709
+ <p>Success criteria and acceptance criteria — organized by delivery status</p>
24710
+ </div>
24711
+ <div class="stats-row">
24712
+ <div class="stat-card">
24713
+ <div class="stat-num">${frozenWithCriteria}</div>
24714
+ <div class="stat-label">criteria met</div>
24715
+ </div>
24716
+ <div class="stat-card">
24717
+ <div class="stat-num">${withCriteria.filter((f) => f["status"] === "active").length}</div>
24718
+ <div class="stat-label">in progress</div>
24719
+ </div>
24720
+ <div class="stat-card">
24721
+ <div class="stat-num">${criteriaPct}%</div>
24722
+ <div class="stat-label">coverage</div>
24723
+ </div>
24724
+ <div class="stat-card">
24725
+ <div class="stat-num">${frozenCount}</div>
24726
+ <div class="stat-label">shipped total</div>
24727
+ </div>
24728
+ </div>
24729
+ </div>
24730
+
24731
+ <div class="progress-bar-wrap">
24732
+ <div class="progress-bar-label">${criteriaPct}% of features have defined success criteria</div>
24733
+ <div class="progress-bar"><div class="progress-fill" style="width:${criteriaPct}%"></div></div>
24734
+ </div>
24735
+
24736
+ <div class="filters">
24737
+ <span class="filter-label">Domain:</span>
24738
+ <span class="domain-pill active" data-domain="__all__" onclick="toggleDomain('__all__')">All <span class="pill-count">${withCriteria.length}</span></span>
24739
+ ${domainPills}
24740
+ </div>
24741
+
24742
+ <div class="board">
24743
+ ${columnHtml}
24744
+ </div>
24745
+
24746
+ ${withoutCriteria.length > 0 ? `
24747
+ <div class="missing-section">
24748
+ <div class="missing-title">⚠ ${withoutCriteria.length} feature${withoutCriteria.length !== 1 ? "s" : ""} missing success criteria${withoutCriteria.length > 12 ? ` (showing 12 of ${withoutCriteria.length})` : ""}</div>
24749
+ <div class="missing-grid">${missingCards}</div>
24750
+ </div>` : ""}
24751
+
24752
+ <script>
24753
+ const DATA = ${dataJson};
24754
+ let activeDomain = '__all__';
24755
+
24756
+ function toggleDomain(d) {
24757
+ activeDomain = d;
24758
+ document.querySelectorAll('.domain-pill').forEach(el => {
24759
+ el.classList.toggle('active', el.dataset.domain === d);
24760
+ });
24761
+ document.querySelectorAll('.card').forEach(el => {
24762
+ const match = d === '__all__' || el.dataset.domain === d;
24763
+ el.style.display = match ? '' : 'none';
24764
+ });
24765
+ // Update column counts
24766
+ ['frozen','active','draft'].forEach(status => {
24767
+ const col = document.getElementById('col-' + status);
24768
+ if (!col) return;
24769
+ const visible = col.querySelectorAll('.card:not([style*="none"])').length;
24770
+ const hdr = col.closest('.column').querySelector('.col-count');
24771
+ if (hdr) hdr.textContent = visible;
24772
+ });
24773
+ }
24774
+ <\/script>
24775
+ </body>
24776
+ </html>`;
24777
+ }
24778
+ //#endregion
24779
+ //#region src/lib/pitchGenerator.ts
24780
+ function esc$2(s) {
24781
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24782
+ }
24783
+ function firstSentence(s) {
24784
+ if (typeof s !== "string") return "";
24785
+ const match = s.match(/^[^.!?]*[.!?]/);
24786
+ return match ? match[0].trim() : s.trim().slice(0, 120);
24787
+ }
24788
+ function hasText(v, min = 1) {
24789
+ return typeof v === "string" && v.trim().length >= min;
24790
+ }
24791
+ const DOMAIN_HUE = {
24792
+ "app-shell": 215,
24793
+ "auth": 195,
24794
+ "recording": 10,
24795
+ "editing": 35,
24796
+ "sessions": 265,
24797
+ "versioning": 155,
24798
+ "collaboration": 320,
24799
+ "band": 55,
24800
+ "render": 180,
24801
+ "storage": 240
24802
+ };
24803
+ function domainBg(domain) {
24804
+ return `radial-gradient(ellipse at 30% 40%, hsl(${DOMAIN_HUE[domain] ?? 215},28%,12%) 0%, #0d0b09 70%)`;
24805
+ }
24806
+ const STATUS_COLOR$1 = {
24807
+ frozen: "#5b82cc",
24808
+ active: "#4aad72",
24809
+ draft: "#c4a255",
24810
+ deprecated: "#664444"
24811
+ };
24812
+ function generatePitch(features, projectName) {
24813
+ const domains = [...new Set(features.map((f) => f["domain"] || "misc"))].sort();
24814
+ const byDomain = /* @__PURE__ */ new Map();
24815
+ for (const f of features) {
24816
+ const d = f["domain"] || "misc";
24817
+ if (!byDomain.has(d)) byDomain.set(d, []);
24818
+ byDomain.get(d).push(f);
24819
+ }
24820
+ const frozen = features.filter((f) => f["status"] === "frozen");
24821
+ const active = features.filter((f) => f["status"] === "active");
24822
+ const draft = features.filter((f) => f["status"] === "draft");
24823
+ const frozenWithGuide = frozen.filter((f) => hasText(f["userGuide"], 20)).sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
24824
+ const allDecisions = [];
24825
+ for (const f of features) {
24826
+ const decs = f["decisions"];
24827
+ if (!Array.isArray(decs)) continue;
24828
+ for (const d of decs) {
24829
+ const obj = d;
24830
+ const rationale = typeof obj["rationale"] === "string" ? obj["rationale"] : "";
24831
+ if (rationale.length > 40) allDecisions.push({
24832
+ decision: String(obj["decision"] ?? ""),
24833
+ rationale,
24834
+ domain: f["domain"] || "",
24835
+ feature: f["title"] || ""
24836
+ });
24837
+ }
24838
+ }
24839
+ allDecisions.sort((a, b) => b.rationale.length - a.rationale.length);
24840
+ const topDecisions = allDecisions.slice(0, 3);
24841
+ const taglineSource = frozen.sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99))[0];
24842
+ const tagline = taglineSource ? firstSentence(taglineSource["problem"]) : "";
24843
+ const slides = [];
24844
+ slides.push({
24845
+ type: "cover",
24846
+ title: projectName,
24847
+ tagline: tagline || `${features.length} features across ${domains.length} domains`,
24848
+ stats: {
24849
+ total: features.length,
24850
+ frozen: frozen.length,
24851
+ active: active.length,
24852
+ draft: draft.length,
24853
+ domains: domains.length
24854
+ }
24855
+ });
24856
+ slides.push({
24857
+ type: "overview",
24858
+ title: "At a Glance",
24859
+ stats: {
24860
+ frozen: frozen.length,
24861
+ active: active.length,
24862
+ draft: draft.length
24863
+ },
24864
+ domains
24865
+ });
24866
+ for (const domain of domains) {
24867
+ const domFeats = (byDomain.get(domain) ?? []).sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
24868
+ slides.push({
24869
+ type: "domain",
24870
+ domain,
24871
+ title: domain.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
24872
+ features: domFeats.slice(0, 8).map((f) => ({
24873
+ title: f["title"],
24874
+ status: f["status"],
24875
+ key: f["featureKey"]
24876
+ })),
24877
+ total: domFeats.length,
24878
+ frozen: domFeats.filter((f) => f["status"] === "frozen").length
24879
+ });
24880
+ }
24881
+ for (const f of frozenWithGuide.slice(0, 12)) {
24882
+ const decs = Array.isArray(f["decisions"]) ? f["decisions"] : [];
24883
+ const topDec = decs[0] ? String(decs[0]["rationale"] ?? "") : "";
24884
+ slides.push({
24885
+ type: "feature",
24886
+ domain: f["domain"] || "",
24887
+ title: f["title"],
24888
+ userGuide: f["userGuide"],
24889
+ keyDecision: firstSentence(topDec),
24890
+ key: f["featureKey"]
24891
+ });
24892
+ }
24893
+ if (topDecisions.length > 0) slides.push({
24894
+ type: "decisions",
24895
+ title: "What We Decided",
24896
+ decisions: topDecisions.map((d) => ({
24897
+ decision: d.decision.slice(0, 100),
24898
+ rationale: firstSentence(d.rationale),
24899
+ domain: d.domain,
24900
+ feature: d.feature
24901
+ }))
24902
+ });
24903
+ if (active.length > 0) slides.push({
24904
+ type: "roadmap",
24905
+ title: "What's Next",
24906
+ features: active.sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99)).slice(0, 8).map((f) => ({
24907
+ title: f["title"],
24908
+ domain: f["domain"],
24909
+ priority: f["priority"],
24910
+ problem: firstSentence(f["problem"])
24911
+ }))
24912
+ });
24913
+ slides.push({
24914
+ type: "outro",
24915
+ stats: {
24916
+ frozen: frozen.length,
24917
+ domains: domains.length,
24918
+ total: features.length
24919
+ }
24920
+ });
24921
+ const slidesJson = JSON.stringify(slides).replace(/<\/script>/gi, "<\\/script>");
24922
+ const domainBgMap = {};
24923
+ for (const d of domains) domainBgMap[d] = domainBg(d);
24924
+ const domainBgJson = JSON.stringify(domainBgMap).replace(/<\/script>/gi, "<\\/script>");
24925
+ const statusColorJson = JSON.stringify(STATUS_COLOR$1).replace(/<\/script>/gi, "<\\/script>");
24926
+ return `<!DOCTYPE html>
24927
+ <html lang="en">
24928
+ <head>
24929
+ <meta charset="UTF-8">
24930
+ <meta name="viewport" content="width=device-width,initial-scale=1">
24931
+ <title>${esc$2(projectName)} — Pitch Deck</title>
24932
+ <style>
24933
+ * { box-sizing: border-box; margin: 0; padding: 0; }
24934
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #0d0b09; color: #e8e0d4; font-family: system-ui, -apple-system, sans-serif; }
24935
+
24936
+ .deck { position: fixed; inset: 0; }
24937
+
24938
+ /* Slide */
24939
+ .slide {
24940
+ position: absolute; inset: 0;
24941
+ display: flex; flex-direction: column;
24942
+ justify-content: center; align-items: center;
24943
+ padding: 48px 64px;
24944
+ transform: translateX(100%);
24945
+ transition: transform 0.42s cubic-bezier(0.4, 0, 0.2, 1);
24946
+ will-change: transform;
24947
+ }
24948
+ .slide.active { transform: translateX(0); }
24949
+ .slide.prev { transform: translateX(-100%); }
24950
+
24951
+ /* Cover */
24952
+ .cover { background: radial-gradient(ellipse at 30% 30%, #1a1508 0%, #0d0b09 65%); }
24953
+ .cover-eyebrow { font-size: 13px; color: #d4a853; font-family: monospace; letter-spacing: .15em; text-transform: uppercase; margin-bottom: 20px; }
24954
+ .cover-title { font-size: clamp(36px, 6vw, 72px); font-weight: 800; text-align: center; line-height: 1.1; letter-spacing: -.02em; margin-bottom: 20px; }
24955
+ .cover-tagline { font-size: clamp(14px, 2vw, 20px); color: #8a7f74; text-align: center; max-width: 640px; line-height: 1.55; margin-bottom: 40px; }
24956
+ .cover-pills { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; }
24957
+ .cover-pill { font-size: 13px; padding: 6px 16px; border-radius: 20px; border: 1px solid #2a2724; color: #8a7f74; font-family: monospace; }
24958
+ .cover-pill span { color: #d4a853; font-weight: 700; }
24959
+
24960
+ /* Overview */
24961
+ .overview { background: #0d0b09; }
24962
+ .slide-title { font-size: clamp(24px, 4vw, 48px); font-weight: 700; text-align: center; margin-bottom: 40px; letter-spacing: -.01em; }
24963
+ .overview-stats { display: flex; gap: 40px; margin-bottom: 48px; flex-wrap: wrap; justify-content: center; }
24964
+ .stat-block { text-align: center; }
24965
+ .stat-num { font-size: clamp(48px, 7vw, 88px); font-weight: 800; font-family: monospace; }
24966
+ .stat-label { font-size: 14px; color: #8a7f74; margin-top: 4px; }
24967
+ .domain-cloud { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; max-width: 700px; }
24968
+ .domain-chip { font-size: 13px; padding: 6px 14px; border-radius: 14px; background: #1a1714; border: 1px solid #2a2724; color: #8a7f74; }
24969
+
24970
+ /* Domain slide */
24971
+ .domain-slide { text-align: left; align-items: flex-start; }
24972
+ .domain-eyebrow { font-size: 11px; color: #d4a853; font-family: monospace; letter-spacing: .15em; text-transform: uppercase; margin-bottom: 16px; }
24973
+ .domain-title { font-size: clamp(32px, 5vw, 64px); font-weight: 800; letter-spacing: -.02em; margin-bottom: 32px; }
24974
+ .domain-features { display: flex; flex-direction: column; gap: 10px; max-width: 640px; }
24975
+ .domain-feat { display: flex; align-items: center; gap: 12px; }
24976
+ .feat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
24977
+ .feat-title { font-size: clamp(13px, 1.8vw, 18px); color: #c8c0b4; line-height: 1.3; }
24978
+ .domain-stat { margin-top: 32px; font-size: 13px; color: #8a7f74; font-family: monospace; }
24979
+
24980
+ /* Feature slide */
24981
+ .feature-slide { align-items: flex-start; text-align: left; }
24982
+ .feature-domain { font-size: 11px; color: #d4a853; font-family: monospace; letter-spacing: .15em; text-transform: uppercase; margin-bottom: 16px; }
24983
+ .feature-title { font-size: clamp(24px, 4vw, 48px); font-weight: 700; line-height: 1.2; letter-spacing: -.01em; margin-bottom: 24px; max-width: 800px; }
24984
+ .feature-guide-label { font-size: 11px; color: #4aad72; font-family: monospace; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 10px; }
24985
+ .feature-guide { font-size: clamp(15px, 2vw, 20px); color: #c8c0b4; line-height: 1.65; max-width: 680px; margin-bottom: 28px; }
24986
+ .feature-decision { font-size: 13px; color: #8a7f74; border-left: 2px solid #d4a853; padding-left: 12px; max-width: 600px; line-height: 1.5; }
24987
+ .feature-key { font-size: 10px; color: #3a3530; font-family: monospace; position: absolute; bottom: 60px; right: 64px; }
24988
+
24989
+ /* Decisions slide */
24990
+ .decisions-grid { display: flex; gap: 24px; flex-wrap: wrap; justify-content: center; max-width: 960px; }
24991
+ .decision-card {
24992
+ flex: 1; min-width: 240px; max-width: 280px;
24993
+ background: #1a1714; border: 1px solid #2a2724; border-radius: 12px;
24994
+ padding: 20px 18px;
24995
+ }
24996
+ .decision-meta { font-size: 10px; color: #8a7f74; font-family: monospace; margin-bottom: 10px; }
24997
+ .decision-text { font-size: 14px; font-weight: 600; color: #e8e0d4; line-height: 1.4; margin-bottom: 10px; }
24998
+ .decision-rationale { font-size: 12px; color: #8a7f74; line-height: 1.55; }
24999
+
25000
+ /* Roadmap */
25001
+ .roadmap-grid { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; max-width: 960px; }
25002
+ .roadmap-card {
25003
+ background: #1a1714; border: 1px solid #2a2724; border-radius: 10px;
25004
+ padding: 16px 16px 14px; min-width: 200px; max-width: 240px; flex: 1;
25005
+ }
25006
+ .roadmap-domain { font-size: 10px; color: #d4a853; font-family: monospace; margin-bottom: 6px; }
25007
+ .roadmap-title { font-size: 13px; font-weight: 600; line-height: 1.35; margin-bottom: 6px; }
25008
+ .roadmap-problem { font-size: 11px; color: #8a7f74; line-height: 1.4; }
25009
+
25010
+ /* Outro */
25011
+ .outro { background: radial-gradient(ellipse at 70% 60%, #0f1008 0%, #0d0b09 65%); }
25012
+ .outro-title { font-size: clamp(28px, 5vw, 56px); font-weight: 800; margin-bottom: 12px; letter-spacing: -.01em; }
25013
+ .outro-sub { font-size: 16px; color: #8a7f74; margin-bottom: 40px; }
25014
+ .outro-stats { display: flex; gap: 32px; flex-wrap: wrap; justify-content: center; margin-bottom: 40px; }
25015
+ .outro-stat { text-align: center; }
25016
+ .outro-num { font-size: 40px; font-weight: 800; font-family: monospace; color: #d4a853; }
25017
+ .outro-label { font-size: 12px; color: #8a7f74; }
25018
+ .lac-badge { font-size: 12px; color: #4a4540; font-family: monospace; }
23152
25019
 
23153
- /* ── Footer ── */
23154
- .footer { margin-top: 64px; padding-top: 24px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
23155
- .footer-brand { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
23156
- .footer-sep { font-family: var(--mono); font-size: 10px; color: var(--border); }
23157
- .footer-note { font-size: 11px; color: var(--text-soft); }
25020
+ /* Nav */
25021
+ .nav { position: fixed; bottom: 0; left: 0; right: 0; display: flex; align-items: center; padding: 0 32px; height: 52px; background: rgba(13,11,9,.85); backdrop-filter: blur(8px); z-index: 100; gap: 16px; }
25022
+ .progress-track { flex: 1; height: 2px; background: #2a2724; border-radius: 1px; overflow: hidden; }
25023
+ .progress-bar { height: 100%; background: #d4a853; border-radius: 1px; transition: width .3s ease; }
25024
+ .slide-counter { font-size: 12px; color: #8a7f74; font-family: monospace; min-width: 48px; text-align: right; }
25025
+ .nav-btn { width: 32px; height: 32px; border-radius: 6px; border: 1px solid #2a2724; background: transparent; color: #8a7f74; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; transition: all .15s; }
25026
+ .nav-btn:hover { border-color: #d4a853; color: #d4a853; }
25027
+ .key-hint { font-size: 10px; color: #3a3530; font-family: monospace; }
25028
+
25029
+ /* Grid overview */
25030
+ .grid-overlay {
25031
+ position: fixed; inset: 0; background: rgba(13,11,9,.96); z-index: 200;
25032
+ display: none; overflow-y: auto; padding: 24px;
25033
+ }
25034
+ .grid-overlay.visible { display: block; }
25035
+ .grid-title { font-size: 14px; color: #8a7f74; font-family: monospace; margin-bottom: 16px; letter-spacing: .05em; }
25036
+ .grid-slides { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
25037
+ .grid-thumb {
25038
+ aspect-ratio: 16/9; border: 1px solid #2a2724; border-radius: 6px; background: #1a1714;
25039
+ cursor: pointer; display: flex; flex-direction: column; justify-content: center; align-items: center;
25040
+ padding: 10px; text-align: center; transition: border-color .15s; overflow: hidden;
25041
+ }
25042
+ .grid-thumb:hover { border-color: #d4a853; }
25043
+ .grid-thumb.active-thumb { border-color: #d4a853; border-width: 2px; }
25044
+ .grid-num { font-size: 9px; color: #4a4540; font-family: monospace; margin-bottom: 4px; }
25045
+ .grid-thumb-title { font-size: 10px; color: #8a7f74; line-height: 1.3; }
25046
+ .grid-thumb-type { font-size: 9px; color: #4a4540; font-family: monospace; margin-top: 3px; }
23158
25047
  </style>
23159
- ${urlPrefix ? "" : `<script>
23160
- function lacGo(file){location.href=location.href.replace(/[^\\/]*$/,'')+file}
23161
- <\/script>`}
23162
25048
  </head>
23163
25049
  <body>
25050
+ <div class="deck" id="deck"></div>
25051
+
25052
+ <div class="nav">
25053
+ <button class="nav-btn" onclick="prev()" title="Previous (←)">‹</button>
25054
+ <div class="progress-track"><div class="progress-bar" id="prog"></div></div>
25055
+ <div class="slide-counter" id="counter">1 / ${slides.length}</div>
25056
+ <button class="nav-btn" onclick="next()" title="Next (→ or Space)">›</button>
25057
+ <span class="key-hint">G=grid P=notes</span>
25058
+ </div>
23164
25059
 
23165
- <div class="topbar">
23166
- <span class="topbar-brand">lac·hub</span>
23167
- <span class="topbar-sep">/</span>
23168
- <span class="topbar-title">${esc(projectName)}</span>
23169
- <span class="topbar-date">Generated ${esc(date)}</span>
25060
+ <div class="grid-overlay" id="grid-overlay">
25061
+ <div class="grid-title">◈ SLIDE OVERVIEW — click to jump · G or Esc to close</div>
25062
+ <div class="grid-slides" id="grid-slides"></div>
23170
25063
  </div>
23171
25064
 
23172
- <div class="page">
25065
+ <script>
25066
+ const SLIDES = ${slidesJson};
25067
+ const DOMAIN_BG = ${domainBgJson};
25068
+ const STATUS_COLOR = ${statusColorJson};
23173
25069
 
23174
- <div class="hero">
23175
- <div class="hero-eyebrow">life-as-code</div>
23176
- <div class="hero-title">${esc(projectName)}</div>
23177
- <div class="hero-sub">Feature documentation hub — every view of the project's feature.json data in one place.</div>
23178
- <div class="stats-row">
23179
- <div class="stat stat-total">
23180
- <div class="stat-num">${stats.total}</div>
23181
- <div class="stat-lbl">features</div>
25070
+ let current = 0;
25071
+ let presenterMode = false;
25072
+ let gridVisible = false;
25073
+
25074
+ function esc(s) {
25075
+ if (s == null) return '';
25076
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
25077
+ }
25078
+
25079
+ function renderSlide(slide, idx) {
25080
+ const isActive = idx === current;
25081
+ const isPrev = idx < current;
25082
+ const cls = isActive ? 'active' : isPrev ? 'prev' : '';
25083
+
25084
+ if (slide.type === 'cover') {
25085
+ const s = slide.stats;
25086
+ return \`<div class="slide cover \${cls}" data-idx="\${idx}">
25087
+ <div class="cover-eyebrow">◈ life-as-code</div>
25088
+ <div class="cover-title">\${esc(slide.title)}</div>
25089
+ \${slide.tagline ? \`<div class="cover-tagline">\${esc(slide.tagline)}</div>\` : ''}
25090
+ <div class="cover-pills">
25091
+ <div class="cover-pill"><span>\${s.frozen}</span> shipped</div>
25092
+ <div class="cover-pill"><span>\${s.active}</span> active</div>
25093
+ <div class="cover-pill"><span>\${s.draft}</span> planned</div>
25094
+ <div class="cover-pill"><span>\${s.domains}</span> domains</div>
25095
+ <div class="cover-pill"><span>\${s.total}</span> features</div>
23182
25096
  </div>
23183
- <div class="stat stat-frozen">
23184
- <div class="stat-num">${stats.frozen}</div>
23185
- <div class="stat-lbl">frozen</div>
25097
+ </div>\`;
25098
+ }
25099
+
25100
+ if (slide.type === 'overview') {
25101
+ const s = slide.stats;
25102
+ return \`<div class="slide \${cls}" data-idx="\${idx}" style="background:#0d0b09">
25103
+ <div class="slide-title">\${esc(slide.title)}</div>
25104
+ <div class="overview-stats">
25105
+ <div class="stat-block"><div class="stat-num" style="color:#5b82cc">\${s.frozen}</div><div class="stat-label">shipped features</div></div>
25106
+ <div class="stat-block"><div class="stat-num" style="color:#4aad72">\${s.active}</div><div class="stat-label">in progress</div></div>
25107
+ <div class="stat-block"><div class="stat-num" style="color:#c4a255">\${s.draft}</div><div class="stat-label">planned</div></div>
23186
25108
  </div>
23187
- <div class="stat stat-active">
23188
- <div class="stat-num">${stats.active}</div>
23189
- <div class="stat-lbl">active</div>
25109
+ <div class="domain-cloud">
25110
+ \${slide.domains.map(d => \`<span class="domain-chip">\${esc(d)}</span>\`).join('')}
23190
25111
  </div>
23191
- <div class="stat stat-draft">
23192
- <div class="stat-num">${stats.draft}</div>
23193
- <div class="stat-lbl">draft</div>
25112
+ </div>\`;
25113
+ }
25114
+
25115
+ if (slide.type === 'domain') {
25116
+ const bg = DOMAIN_BG[slide.domain] || 'radial-gradient(ellipse at 30% 40%, #181410 0%, #0d0b09 70%)';
25117
+ return \`<div class="slide domain-slide \${cls}" data-idx="\${idx}" style="background:\${bg}">
25118
+ <div class="domain-eyebrow">domain</div>
25119
+ <div class="domain-title">\${esc(slide.title)}</div>
25120
+ <div class="domain-features">
25121
+ \${slide.features.map(f => \`<div class="domain-feat">
25122
+ <span class="feat-dot" style="background:\${STATUS_COLOR[f.status]||'#888'}"></span>
25123
+ <span class="feat-title">\${esc(f.title)}</span>
25124
+ </div>\`).join('')}
23194
25125
  </div>
23195
- <div class="stat">
23196
- <div class="stat-num" style="color:var(--text-mid)">${stats.domains.length}</div>
23197
- <div class="stat-lbl">domains</div>
25126
+ <div class="domain-stat">\${slide.total} feature\${slide.total!==1?'s':''} · \${slide.frozen} shipped</div>
25127
+ </div>\`;
25128
+ }
25129
+
25130
+ if (slide.type === 'feature') {
25131
+ const bg = DOMAIN_BG[slide.domain] || 'radial-gradient(ellipse at 30% 40%, #181410 0%, #0d0b09 70%)';
25132
+ return \`<div class="slide feature-slide \${cls}" data-idx="\${idx}" style="background:\${bg}">
25133
+ <div class="feature-domain">\${esc(slide.domain)}</div>
25134
+ <div class="feature-title">\${esc(slide.title)}</div>
25135
+ <div class="feature-guide-label">what you can do</div>
25136
+ <div class="feature-guide">\${esc(String(slide.userGuide||''))}</div>
25137
+ \${slide.keyDecision && presenterMode ? \`<div class="feature-decision">💡 \${esc(slide.keyDecision)}</div>\` : ''}
25138
+ <div class="feature-key">\${esc(slide.key)}</div>
25139
+ </div>\`;
25140
+ }
25141
+
25142
+ if (slide.type === 'decisions') {
25143
+ return \`<div class="slide \${cls}" data-idx="\${idx}" style="background:#0d0b09">
25144
+ <div class="slide-title">\${esc(slide.title)}</div>
25145
+ <div class="decisions-grid">
25146
+ \${slide.decisions.map(d => \`<div class="decision-card">
25147
+ <div class="decision-meta">\${esc(d.domain)} · \${esc(d.feature.slice(0,40))}</div>
25148
+ <div class="decision-text">\${esc(d.decision)}</div>
25149
+ <div class="decision-rationale">\${esc(d.rationale)}</div>
25150
+ </div>\`).join('')}
23198
25151
  </div>
23199
- </div>
23200
- ${domainList ? `<div class="domains-row">${domainList}</div>` : ""}
23201
- </div>
25152
+ </div>\`;
25153
+ }
25154
+
25155
+ if (slide.type === 'roadmap') {
25156
+ return \`<div class="slide \${cls}" data-idx="\${idx}" style="background:#0d0b09">
25157
+ <div class="slide-title">\${esc(slide.title)}</div>
25158
+ <div class="roadmap-grid">
25159
+ \${slide.features.map(f => \`<div class="roadmap-card">
25160
+ \${f.domain ? \`<div class="roadmap-domain">\${esc(f.domain)}</div>\` : ''}
25161
+ <div class="roadmap-title">\${esc(f.title)}</div>
25162
+ \${f.problem ? \`<div class="roadmap-problem">\${esc(f.problem)}</div>\` : ''}
25163
+ </div>\`).join('')}
25164
+ </div>
25165
+ </div>\`;
25166
+ }
25167
+
25168
+ if (slide.type === 'outro') {
25169
+ const s = slide.stats;
25170
+ return \`<div class="slide outro \${cls}" data-idx="\${idx}">
25171
+ <div class="outro-title">\${esc(SLIDES[0].title)}</div>
25172
+ <div class="outro-sub">Built with life-as-code</div>
25173
+ <div class="outro-stats">
25174
+ <div class="outro-stat"><div class="outro-num">\${s.frozen}</div><div class="outro-label">features shipped</div></div>
25175
+ <div class="outro-stat"><div class="outro-num">\${s.domains}</div><div class="outro-label">domains</div></div>
25176
+ <div class="outro-stat"><div class="outro-num">\${s.total}</div><div class="outro-label">total features</div></div>
25177
+ </div>
25178
+ <div class="lac-badge">◈ lac · /lac/</div>
25179
+ </div>\`;
25180
+ }
23202
25181
 
23203
- ${primaryEntries.length > 0 ? `
23204
- <div class="section-header">
23205
- <div class="section-title">User-facing</div>
23206
- <div class="section-rule"></div>
23207
- </div>
23208
- <div class="primary-grid">
23209
- ${primaryEntries.map(primaryCard).join("")}
23210
- </div>` : ""}
25182
+ return \`<div class="slide \${cls}" data-idx="\${idx}" style="background:#0d0b09">
25183
+ <div class="slide-title">\${esc(slide.title||slide.type)}</div>
25184
+ </div>\`;
25185
+ }
23211
25186
 
23212
- ${secondaryEntries.length > 0 ? `
23213
- <div class="section-header">
23214
- <div class="section-title">Developer views</div>
23215
- <div class="section-rule"></div>
23216
- </div>
23217
- <div class="secondary-grid">
23218
- ${secondaryEntries.map(secondaryCard).join("")}
23219
- </div>` : ""}
25187
+ function render() {
25188
+ const deck = document.getElementById('deck');
25189
+ const start = Math.max(0, current - 1);
25190
+ const end = Math.min(SLIDES.length - 1, current + 1);
23220
25191
 
23221
- <div class="footer">
23222
- <span class="footer-brand">@majeanson/lac</span>
23223
- <span class="footer-sep">//</span>
23224
- <span class="footer-note">Generated from feature.json files. Run <code style="font-family:var(--mono);font-size:10px;background:#1e1a16;padding:1px 5px;border-radius:3px">lac export --all</code> to regenerate.</span>
23225
- </div>
25192
+ // Remove stale slides
25193
+ deck.querySelectorAll('.slide').forEach(el => {
25194
+ const idx = parseInt(el.dataset.idx, 10);
25195
+ if (idx < start || idx > end) el.remove();
25196
+ });
23226
25197
 
23227
- </div>
23228
- </body>
23229
- </html>`;
23230
- }
23231
- //#endregion
23232
- //#region src/lib/views.ts
23233
- /** Fields always shown at summary density regardless of view */
23234
- const SUMMARY_FIELDS = new Set([
23235
- "featureKey",
23236
- "title",
23237
- "status",
23238
- "domain",
23239
- "priority",
23240
- "tags",
23241
- "problem"
23242
- ]);
23243
- const VIEW_NAMES = [
23244
- "dev",
23245
- "product",
23246
- "user",
23247
- "support",
23248
- "tech"
23249
- ];
23250
- /** Always-present identity fields included in every view */
23251
- const IDENTITY = [
23252
- "featureKey",
23253
- "title",
23254
- "status",
23255
- "domain"
23256
- ];
23257
- const VIEWS = {
23258
- user: {
23259
- name: "user",
23260
- label: "User",
23261
- description: "Plain-language guide — what the feature does and why it exists",
23262
- fields: new Set([
23263
- "title",
23264
- "problem",
23265
- "userGuide",
23266
- "successCriteria",
23267
- "tags"
23268
- ])
23269
- },
23270
- support: {
23271
- name: "support",
23272
- label: "Support",
23273
- description: "Known limitations, annotations, and escalation context for support teams",
23274
- fields: new Set([
23275
- ...IDENTITY,
23276
- "owner",
23277
- "problem",
23278
- "knownLimitations",
23279
- "annotations",
23280
- "tags"
23281
- ])
23282
- },
23283
- product: {
23284
- name: "product",
23285
- label: "Product",
23286
- description: "Business problem, success criteria, and strategic decisions — no implementation details",
23287
- fields: new Set([
23288
- ...IDENTITY,
23289
- "owner",
23290
- "priority",
23291
- "problem",
23292
- "analysis",
23293
- "userGuide",
23294
- "pmSummary",
23295
- "successCriteria",
23296
- "acceptanceCriteria",
23297
- "decisions",
23298
- "knownLimitations",
23299
- "tags",
23300
- "releaseVersion"
23301
- ])
23302
- },
23303
- dev: {
23304
- name: "dev",
23305
- label: "Developer",
23306
- description: "Full implementation context — code, decisions, snippets, and lineage",
23307
- fields: new Set([
23308
- ...IDENTITY,
23309
- "owner",
23310
- "priority",
23311
- "problem",
23312
- "analysis",
23313
- "implementation",
23314
- "implementationNotes",
23315
- "userGuide",
23316
- "successCriteria",
23317
- "acceptanceCriteria",
23318
- "testStrategy",
23319
- "decisions",
23320
- "knownLimitations",
23321
- "tags",
23322
- "annotations",
23323
- "lineage",
23324
- "componentFile",
23325
- "npmPackages",
23326
- "publicInterface",
23327
- "externalDependencies",
23328
- "codeSnippets"
23329
- ])
23330
- },
23331
- tech: {
23332
- name: "tech",
23333
- label: "Technical",
23334
- description: "Complete technical record — all fields including history, revisions, and lineage",
23335
- fields: new Set([
23336
- ...IDENTITY,
23337
- "schemaVersion",
23338
- "owner",
23339
- "priority",
23340
- "problem",
23341
- "analysis",
23342
- "implementation",
23343
- "implementationNotes",
23344
- "userGuide",
23345
- "pmSummary",
23346
- "successCriteria",
23347
- "acceptanceCriteria",
23348
- "testStrategy",
23349
- "decisions",
23350
- "knownLimitations",
23351
- "tags",
23352
- "annotations",
23353
- "lineage",
23354
- "statusHistory",
23355
- "revisions",
23356
- "componentFile",
23357
- "npmPackages",
23358
- "publicInterface",
23359
- "externalDependencies",
23360
- "codeSnippets",
23361
- "lastVerifiedDate",
23362
- "releaseVersion",
23363
- "superseded_by",
23364
- "superseded_from",
23365
- "merged_into",
23366
- "merged_from"
23367
- ])
23368
- }
23369
- };
23370
- /**
23371
- * Return a copy of `feature` with only the keys allowed by `view`.
23372
- * Fields not in the view's set are omitted entirely.
23373
- */
23374
- function applyView(feature, view) {
23375
- const result = {};
23376
- for (const key of Object.keys(feature)) if (view.fields.has(key)) result[key] = feature[key];
23377
- return result;
25198
+ // Render/update visible slides
25199
+ for (let i = start; i <= end; i++) {
25200
+ const existing = deck.querySelector(\`.slide[data-idx="\${i}"]\`);
25201
+ const html = renderSlide(SLIDES[i], i);
25202
+ if (existing) {
25203
+ const cls = i === current ? 'active' : i < current ? 'prev' : '';
25204
+ existing.className = existing.className.replace(/\\b(active|prev)\\b/g, '').trim() + (cls ? ' ' + cls : '');
25205
+ } else {
25206
+ deck.insertAdjacentHTML('beforeend', html);
25207
+ }
25208
+ }
25209
+
25210
+ // Progress
25211
+ const pct = SLIDES.length > 1 ? (current / (SLIDES.length - 1)) * 100 : 100;
25212
+ document.getElementById('prog').style.width = pct + '%';
25213
+ document.getElementById('counter').textContent = (current + 1) + ' / ' + SLIDES.length;
25214
+
25215
+ // Grid thumbs
25216
+ document.querySelectorAll('.grid-thumb').forEach(el => {
25217
+ const idx = parseInt(el.dataset.idx, 10);
25218
+ el.classList.toggle('active-thumb', idx === current);
25219
+ });
23378
25220
  }
23379
- /**
23380
- * Fields the HTML wiki renderer requires for sidebar navigation and routing.
23381
- * These are always preserved regardless of view, so the wiki remains navigable.
23382
- */
23383
- const HTML_NAV_FIELDS = new Set([
23384
- "featureKey",
23385
- "title",
23386
- "status",
23387
- "domain",
23388
- "lineage",
23389
- "priority"
23390
- ]);
23391
- /**
23392
- * Like `applyView`, but always preserves HTML navigation fields so the wiki
23393
- * sidebar and routing continue to work correctly.
23394
- */
23395
- function applyViewForHtml(feature, view) {
23396
- const result = {};
23397
- for (const key of Object.keys(feature)) if (view.fields.has(key) || HTML_NAV_FIELDS.has(key)) result[key] = feature[key];
23398
- return result;
25221
+
25222
+ function next() { if (current < SLIDES.length - 1) { current++; render(); } }
25223
+ function prev() { if (current > 0) { current--; render(); } }
25224
+
25225
+ function toggleGrid() {
25226
+ gridVisible = !gridVisible;
25227
+ const overlay = document.getElementById('grid-overlay');
25228
+ overlay.classList.toggle('visible', gridVisible);
25229
+ if (gridVisible && !document.getElementById('grid-slides').children.length) {
25230
+ document.getElementById('grid-slides').innerHTML = SLIDES.map((s, i) =>
25231
+ \`<div class="grid-thumb\${i===current?' active-thumb':''}" data-idx="\${i}" onclick="jumpTo(\${i})">
25232
+ <div class="grid-num">\${i+1}</div>
25233
+ <div class="grid-thumb-title">\${esc(s.title||s.type)}</div>
25234
+ <div class="grid-thumb-type">\${esc(s.type)}</div>
25235
+ </div>\`
25236
+ ).join('');
25237
+ }
23399
25238
  }
23400
- /**
23401
- * Apply density filtering to a feature.
23402
- * - summary: only SUMMARY_FIELDS (title, status, domain, priority, tags, problem snippet)
23403
- * - standard: pass through unchanged (generators decide what to show)
23404
- * - verbose: pass through unchanged but callers should render VERBOSE_EXTRA_FIELDS too
23405
- *
23406
- * Returns the filtered feature and the resolved density level.
23407
- */
23408
- function applyDensity(feature, density) {
23409
- if (density === "standard" || density === "verbose") return feature;
23410
- const result = {};
23411
- for (const key of Object.keys(feature)) if (SUMMARY_FIELDS.has(key)) if (key === "problem" && typeof feature[key] === "string") {
23412
- const prob = feature[key];
23413
- const firstSentence = prob.split(/[.!?]\s/)[0] ?? prob;
23414
- result[key] = firstSentence.length < prob.length ? firstSentence + "." : prob;
23415
- } else result[key] = feature[key];
23416
- return result;
25239
+
25240
+ function jumpTo(idx) {
25241
+ current = idx;
25242
+ gridVisible = false;
25243
+ document.getElementById('grid-overlay').classList.remove('visible');
25244
+ render();
23417
25245
  }
23418
- /**
23419
- * Resolve a view name against both built-in views and custom views from lac.config.json.
23420
- *
23421
- * Resolution order:
23422
- * 1. If `name` matches a key in `customViews` (from lac.config.json), build a ViewConfig from it.
23423
- * If it has `extends`, merge on top of the built-in base.
23424
- * 2. Otherwise fall back to VIEWS[name].
23425
- * 3. If neither matches, return undefined.
23426
- */
23427
- function resolveView(name, customViews = {}) {
23428
- const custom = customViews[name];
23429
- if (custom) {
23430
- const base = custom.extends ? VIEWS[custom.extends] : void 0;
23431
- const baseFields = base ? new Set(base.fields) : /* @__PURE__ */ new Set();
23432
- const fields = custom.fields ? new Set(custom.fields) : baseFields;
23433
- for (const f of IDENTITY) fields.add(f);
23434
- return {
23435
- name,
23436
- label: custom.label ?? base?.label ?? name,
23437
- description: custom.description ?? base?.description ?? `Custom view: ${name}`,
23438
- fields,
23439
- density: custom.density,
23440
- groupBy: custom.groupBy,
23441
- sortBy: custom.sortBy,
23442
- filterStatus: custom.filterStatus,
23443
- sections: custom.sections
23444
- };
25246
+
25247
+ // Keyboard
25248
+ document.addEventListener('keydown', e => {
25249
+ if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); next(); }
25250
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); }
25251
+ else if (e.key === 'g' || e.key === 'G') toggleGrid();
25252
+ else if (e.key === 'Escape' && gridVisible) { gridVisible = false; document.getElementById('grid-overlay').classList.remove('visible'); }
25253
+ else if (e.key === 'p' || e.key === 'P') { presenterMode = !presenterMode; render(); }
25254
+ });
25255
+
25256
+ // Touch swipe
25257
+ let tx = 0;
25258
+ document.addEventListener('touchstart', e => { tx = e.touches[0].clientX; }, { passive: true });
25259
+ document.addEventListener('touchend', e => {
25260
+ const dx = e.changedTouches[0].clientX - tx;
25261
+ if (Math.abs(dx) > 50) dx < 0 ? next() : prev();
25262
+ });
25263
+
25264
+ // Init
25265
+ render();
25266
+ <\/script>
25267
+ </body>
25268
+ </html>`;
25269
+ }
25270
+ //#endregion
25271
+ //#region src/lib/timelineGenerator.ts
25272
+ function esc$1(s) {
25273
+ return String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
25274
+ }
25275
+ const STATUS_COLOR = {
25276
+ frozen: "#5b82cc",
25277
+ active: "#4aad72",
25278
+ draft: "#c4a255",
25279
+ deprecated: "#664444"
25280
+ };
25281
+ const DOMAIN_ORDER = [
25282
+ "app-shell",
25283
+ "auth",
25284
+ "recording",
25285
+ "editing",
25286
+ "sessions",
25287
+ "versioning",
25288
+ "collaboration",
25289
+ "band",
25290
+ "render",
25291
+ "storage"
25292
+ ];
25293
+ function getFeatureDates(f) {
25294
+ const history = f["statusHistory"];
25295
+ const transitions = [];
25296
+ if (Array.isArray(history)) {
25297
+ for (const entry of history) {
25298
+ const e = entry;
25299
+ const dateStr = e["date"];
25300
+ if (dateStr && typeof dateStr === "string") {
25301
+ const d = new Date(dateStr);
25302
+ if (!isNaN(d.getTime())) transitions.push({
25303
+ from: String(e["from"] ?? ""),
25304
+ to: String(e["to"] ?? ""),
25305
+ date: d
25306
+ });
25307
+ }
25308
+ }
25309
+ transitions.sort((a, b) => a.date.getTime() - b.date.getTime());
23445
25310
  }
23446
- return VIEW_NAMES.includes(name) ? VIEWS[name] : void 0;
25311
+ if (transitions.length === 0) return {
25312
+ start: null,
25313
+ end: null,
25314
+ transitions
25315
+ };
25316
+ const start = transitions[0].date;
25317
+ const lastStatus = f["status"] || "draft";
25318
+ return {
25319
+ start,
25320
+ end: lastStatus === "frozen" || lastStatus === "deprecated" ? transitions[transitions.length - 1].date : /* @__PURE__ */ new Date(),
25321
+ transitions
25322
+ };
23447
25323
  }
23448
- /**
23449
- * Sort and filter a feature list according to a resolved view profile.
23450
- * This is called before passing features to any generator.
23451
- */
23452
- function applyViewTransforms(features, profile) {
23453
- let result = [...features];
23454
- if (profile.filterStatus && profile.filterStatus.length > 0) result = result.filter((f) => profile.filterStatus.includes(f["status"]));
23455
- if (profile.sortBy === "priority") result.sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
23456
- else if (profile.sortBy === "title") result.sort((a, b) => String(a["title"] ?? "").localeCompare(String(b["title"] ?? "")));
23457
- else if (profile.sortBy === "status") {
23458
- const order = {
23459
- active: 0,
23460
- draft: 1,
23461
- frozen: 2,
23462
- deprecated: 3
25324
+ function generateTimeline(features, projectName) {
25325
+ const today = /* @__PURE__ */ new Date();
25326
+ const allDates = [];
25327
+ for (const f of features) {
25328
+ const { start, end } = getFeatureDates(f);
25329
+ if (start) allDates.push(start);
25330
+ if (end) allDates.push(end);
25331
+ }
25332
+ const minDate = allDates.length > 0 ? new Date(Math.min(...allDates.map((d) => d.getTime()))) : new Date(today.getFullYear() - 1, 0, 1);
25333
+ const maxDate = today;
25334
+ const padded = (maxDate.getTime() - minDate.getTime()) * .04;
25335
+ const domainStart = new Date(minDate.getTime() - padded);
25336
+ const domainEnd = new Date(maxDate.getTime() + padded);
25337
+ const totalMs = domainEnd.getTime() - domainStart.getTime();
25338
+ function toPct(d) {
25339
+ return (d.getTime() - domainStart.getTime()) / totalMs * 100;
25340
+ }
25341
+ const domains = [...DOMAIN_ORDER.filter((d) => features.some((f) => f["domain"] === d)), ...[...new Set(features.map((f) => f["domain"] || "misc"))].filter((d) => !DOMAIN_ORDER.includes(d)).sort()];
25342
+ const byDomain = /* @__PURE__ */ new Map();
25343
+ for (const f of features) {
25344
+ const d = f["domain"] || "misc";
25345
+ if (!byDomain.has(d)) byDomain.set(d, []);
25346
+ byDomain.get(d).push(f);
25347
+ }
25348
+ const ticks = [];
25349
+ const cursor = new Date(domainStart.getFullYear(), domainStart.getMonth(), 1);
25350
+ while (cursor <= domainEnd) {
25351
+ ticks.push({
25352
+ date: new Date(cursor),
25353
+ label: cursor.toLocaleString("en", {
25354
+ month: "short",
25355
+ year: "2-digit"
25356
+ })
25357
+ });
25358
+ cursor.setMonth(cursor.getMonth() + 1);
25359
+ }
25360
+ const featData = features.map((f) => {
25361
+ const { start, end, transitions } = getFeatureDates(f);
25362
+ return {
25363
+ key: String(f["featureKey"] ?? ""),
25364
+ title: String(f["title"] ?? ""),
25365
+ status: String(f["status"] ?? "draft"),
25366
+ domain: String(f["domain"] ?? "misc"),
25367
+ startPct: start ? toPct(start) : null,
25368
+ endPct: end ? toPct(end) : null,
25369
+ hasHistory: transitions.length > 0,
25370
+ transitions: transitions.map((t) => ({
25371
+ from: t.from,
25372
+ to: t.to,
25373
+ date: t.date.toISOString().slice(0, 10)
25374
+ }))
23463
25375
  };
23464
- result.sort((a, b) => (order[a["status"]] ?? 9) - (order[b["status"]] ?? 9));
23465
- } else if (profile.sortBy === "lastVerifiedDate") result.sort((a, b) => String(b["lastVerifiedDate"] ?? "").localeCompare(String(a["lastVerifiedDate"] ?? "")));
23466
- return result;
25376
+ });
25377
+ const dataJson = JSON.stringify({
25378
+ projectName,
25379
+ today: today.toISOString().slice(0, 10),
25380
+ todayPct: toPct(today),
25381
+ domains,
25382
+ features: featData,
25383
+ ticks: ticks.map((t) => ({
25384
+ label: t.label,
25385
+ pct: toPct(t.date)
25386
+ }))
25387
+ }).replace(/<\/script>/gi, "<\\/script>");
25388
+ const statusColorJson = JSON.stringify(STATUS_COLOR).replace(/<\/script>/gi, "<\\/script>");
25389
+ const featuresWithHistory = features.filter((f) => getFeatureDates(f).transitions.length > 0).length;
25390
+ const featuresWithoutHistory = features.length - featuresWithHistory;
25391
+ return `<!DOCTYPE html>
25392
+ <html lang="en">
25393
+ <head>
25394
+ <meta charset="UTF-8">
25395
+ <meta name="viewport" content="width=device-width,initial-scale=1">
25396
+ <title>${esc$1(projectName)} — Feature Timeline</title>
25397
+ <style>
25398
+ :root {
25399
+ --bg: #12100e; --bg-card: #1a1714; --bg-hover: #201d1a;
25400
+ --border: #2a2724; --border-soft: #221f1c;
25401
+ --text: #e8e0d4; --text-soft: #8a7f74; --accent: #d4a853;
25402
+ --lane-h: 52px; --label-w: 120px;
25403
+ --mono: 'SF Mono','Fira Code','Cascadia Code',monospace;
25404
+ }
25405
+ * { box-sizing: border-box; margin: 0; padding: 0; }
25406
+ html, body { height: 100%; background: var(--bg); color: var(--text); font-family: system-ui,-apple-system,sans-serif; overflow: hidden; display: flex; flex-direction: column; }
25407
+
25408
+ .topbar {
25409
+ display: flex; align-items: center; gap: 10px; padding: 0 20px; height: 48px;
25410
+ background: #0e0c0a; border-bottom: 1px solid var(--border); flex-shrink: 0; font-size: 13px;
25411
+ }
25412
+ .topbar-logo { color: var(--accent); font-weight: 700; font-family: var(--mono); }
25413
+ .topbar-sep { color: var(--border); }
25414
+ .topbar-count { margin-left: auto; color: var(--text-soft); font-size: 12px; font-family: var(--mono); }
25415
+
25416
+ .controls {
25417
+ display: flex; align-items: center; gap: 12px; padding: 10px 20px;
25418
+ background: var(--bg-card); border-bottom: 1px solid var(--border); flex-shrink: 0;
25419
+ font-size: 12px;
25420
+ }
25421
+ .ctrl-label { color: var(--text-soft); font-family: var(--mono); }
25422
+ .ctrl-btn {
25423
+ padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border);
25424
+ background: transparent; color: var(--text-soft); cursor: pointer; font-size: 12px; font-family: var(--mono);
25425
+ transition: all .15s;
25426
+ }
25427
+ .ctrl-btn:hover { border-color: var(--accent); color: var(--accent); }
25428
+ .ctrl-sep { color: var(--border); }
25429
+ .legend { display: flex; gap: 14px; margin-left: auto; }
25430
+ .leg-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-soft); }
25431
+ .leg-dot { width: 10px; height: 10px; border-radius: 2px; }
25432
+ .zoom-info { font-size: 11px; color: var(--text-soft); font-family: var(--mono); }
25433
+
25434
+ .timeline-wrap { flex: 1; overflow: hidden; position: relative; display: flex; flex-direction: column; }
25435
+
25436
+ /* Tick labels row */
25437
+ .tick-row {
25438
+ display: flex; height: 32px; flex-shrink: 0;
25439
+ padding-left: var(--label-w); position: relative; overflow: hidden;
25440
+ border-bottom: 1px solid var(--border);
25441
+ }
25442
+ .tick-track { position: absolute; left: var(--label-w); right: 0; top: 0; bottom: 0; }
25443
+
25444
+ /* Lanes */
25445
+ .lanes-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; position: relative; }
25446
+ .lanes-inner { position: relative; }
25447
+ .lane { display: flex; height: var(--lane-h); border-bottom: 1px solid var(--border-soft); }
25448
+ .lane:hover { background: rgba(255,255,255,.012); }
25449
+ .lane-label {
25450
+ width: var(--label-w); flex-shrink: 0; padding: 0 12px 0 16px;
25451
+ display: flex; align-items: center; border-right: 1px solid var(--border);
25452
+ position: sticky; left: 0; background: var(--bg); z-index: 2;
25453
+ }
25454
+ .lane-label-text { font-size: 11px; color: var(--text-soft); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
25455
+ .lane-track { flex: 1; position: relative; }
25456
+
25457
+ /* Feature pill */
25458
+ .feat-pill {
25459
+ position: absolute; top: 8px; height: 34px;
25460
+ border-radius: 5px; cursor: pointer;
25461
+ display: flex; align-items: center; padding: 0 6px;
25462
+ transition: filter .15s, z-index .15s;
25463
+ border: 1px solid rgba(255,255,255,.08);
25464
+ min-width: 6px; overflow: hidden;
25465
+ }
25466
+ .feat-pill:hover { filter: brightness(1.3); z-index: 10; }
25467
+ .feat-pill-text { font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: rgba(255,255,255,.85); font-family: var(--mono); }
25468
+
25469
+ /* Dot for features without history */
25470
+ .feat-dot {
25471
+ position: absolute; top: 50%; width: 8px; height: 8px;
25472
+ border-radius: 50%; transform: translate(-50%, -50%);
25473
+ cursor: pointer; border: 1px solid rgba(255,255,255,.2);
25474
+ }
25475
+
25476
+ /* Today line */
25477
+ .today-line { position: absolute; top: 0; bottom: 0; width: 1.5px; background: rgba(212,168,83,.5); pointer-events: none; z-index: 5; }
25478
+ .today-label { position: absolute; top: 4px; font-size: 9px; color: var(--accent); font-family: var(--mono); transform: translateX(-50%); white-space: nowrap; }
25479
+
25480
+ /* Tooltip */
25481
+ .tooltip {
25482
+ position: fixed; pointer-events: none; z-index: 999;
25483
+ background: #1e1b18; border: 1px solid var(--border); border-radius: 8px;
25484
+ padding: 12px 14px; font-size: 12px; color: var(--text); max-width: 260px;
25485
+ display: none; box-shadow: 0 8px 24px rgba(0,0,0,.5);
25486
+ }
25487
+ .tooltip.visible { display: block; }
25488
+ .tooltip-title { font-weight: 600; margin-bottom: 6px; font-size: 13px; line-height: 1.3; }
25489
+ .tooltip-row { display: flex; justify-content: space-between; gap: 20px; margin: 2px 0; font-size: 11px; }
25490
+ .tooltip-label { color: var(--text-soft); }
25491
+ .tooltip-val { font-family: var(--mono); }
25492
+ .tooltip-hist { margin-top: 8px; border-top: 1px solid var(--border); padding-top: 8px; }
25493
+ .tooltip-trans { font-size: 10px; color: var(--text-soft); font-family: var(--mono); margin: 1px 0; }
25494
+ </style>
25495
+ </head>
25496
+ <body>
25497
+ <div class="topbar">
25498
+ <span class="topbar-logo">◈ lac</span>
25499
+ <span class="topbar-sep">|</span>
25500
+ <span class="topbar-project">${esc$1(projectName)} — Feature Timeline</span>
25501
+ <span class="topbar-count">${features.length} features · ${featuresWithHistory} with history · ${featuresWithoutHistory} undated</span>
25502
+ </div>
25503
+
25504
+ <div class="controls">
25505
+ <span class="ctrl-label">Zoom:</span>
25506
+ <button class="ctrl-btn" onclick="zoom(0.7)">−</button>
25507
+ <span class="zoom-info" id="zoom-info">100%</span>
25508
+ <button class="ctrl-btn" onclick="zoom(1.4)">+</button>
25509
+ <button class="ctrl-btn" onclick="resetZoom()">Reset</button>
25510
+ <span class="ctrl-sep">|</span>
25511
+ <span class="ctrl-label">Sort:</span>
25512
+ <button class="ctrl-btn" onclick="setSortMode('domain')">Domain</button>
25513
+ <button class="ctrl-btn" onclick="setSortMode('start')">Start date</button>
25514
+ <button class="ctrl-btn" onclick="setSortMode('status')">Status</button>
25515
+ <div class="legend">
25516
+ ${Object.entries(STATUS_COLOR).map(([s, c]) => `<div class="leg-item"><div class="leg-dot" style="background:${c}"></div>${s}</div>`).join("")}
25517
+ <div class="leg-item"><div class="leg-dot" style="background:#3a3530;border:1px solid #5a5550"></div>no history</div>
25518
+ </div>
25519
+ </div>
25520
+
25521
+ <div class="timeline-wrap">
25522
+ <div class="tick-row">
25523
+ <div class="tick-track" id="tick-track"></div>
25524
+ </div>
25525
+ <div class="lanes-scroll">
25526
+ <div class="lanes-inner" id="lanes"></div>
25527
+ </div>
25528
+ </div>
25529
+
25530
+ <div class="tooltip" id="tooltip"></div>
25531
+
25532
+ <script>
25533
+ const DATA = ${dataJson};
25534
+ const STATUS_COLOR = ${statusColorJson};
25535
+
25536
+ let zoomLevel = 1;
25537
+ let sortMode = 'domain';
25538
+ const tooltip = document.getElementById('tooltip');
25539
+
25540
+ // Scale: zoomLevel * 100% width for the track
25541
+ function trackWidth() { return Math.round(zoomLevel * 100) + '%'; }
25542
+
25543
+ function pctToPx(pct) {
25544
+ const track = document.getElementById('lanes');
25545
+ return (pct / 100) * track.offsetWidth;
25546
+ }
25547
+
25548
+ function render() {
25549
+ document.getElementById('zoom-info').textContent = Math.round(zoomLevel * 100) + '%';
25550
+ renderTicks();
25551
+ renderLanes();
25552
+ }
25553
+
25554
+ function renderTicks() {
25555
+ const track = document.getElementById('tick-track');
25556
+ track.style.width = trackWidth();
25557
+ track.innerHTML = DATA.ticks.map(t =>
25558
+ t.pct >= 0 && t.pct <= 100
25559
+ ? \`<div style="position:absolute;left:\${t.pct}%;top:0;bottom:0;border-left:1px solid #2a2724;padding-top:8px">
25560
+ <span style="font-size:9px;color:#6a6055;font-family:var(--mono);padding-left:4px">\${t.label}</span>
25561
+ </div>\`
25562
+ : ''
25563
+ ).join('') + \`<div class="today-line" style="left:\${DATA.todayPct}%">
25564
+ <div class="today-label" style="left:50%">today</div>
25565
+ </div>\`;
25566
+ }
25567
+
25568
+ function renderLanes() {
25569
+ const lanesEl = document.getElementById('lanes');
25570
+ // Sort domains
25571
+ let domainOrder = [...DATA.domains];
25572
+ if (sortMode === 'status') {
25573
+ // sort domains by avg frozen ratio descending
25574
+ }
25575
+
25576
+ lanesEl.innerHTML = domainOrder.map(domain => {
25577
+ const domFeats = DATA.features.filter(f => f.domain === domain);
25578
+ // Sort features within domain
25579
+ let sorted = [...domFeats];
25580
+ if (sortMode === 'start') sorted.sort((a,b) => (a.startPct??101) - (b.startPct??101));
25581
+ else if (sortMode === 'status') sorted.sort((a,b) => a.status.localeCompare(b.status));
25582
+ else sorted.sort((a,b) => (a.startPct??101) - (b.startPct??101));
25583
+
25584
+ const laneH = Math.max(${DOMAIN_ORDER.length > 0 ? "Math.ceil(sorted.length / 1) * 44 + 8" : "52"}, 52);
25585
+
25586
+ const pills = sorted.map((f, fi) => {
25587
+ const color = STATUS_COLOR[f.status] || '#444';
25588
+ const bgAlpha = f.hasHistory ? '55' : '22';
25589
+ const top = 8 + Math.floor(fi * 0) ; // stack vertically — one row per feature lane
25590
+ if (f.startPct !== null && f.endPct !== null) {
25591
+ const left = Math.max(0, f.startPct);
25592
+ const width = Math.max(0.4, f.endPct - f.startPct);
25593
+ return \`<div class="feat-pill"
25594
+ style="left:\${left}%;width:\${width}%;background:\${color}\${bgAlpha};top:8px"
25595
+ data-key="\${f.key}"
25596
+ onmousemove="showTooltip(event, '\${f.key}')"
25597
+ onmouseleave="hideTooltip()"
25598
+ onclick="window.open('lac-wiki.html#\${f.key}','_self')">
25599
+ \${width > 2 ? \`<span class="feat-pill-text">\${f.title}</span>\` : ''}
25600
+ </div>\`;
25601
+ } else {
25602
+ // dot at end of timeline
25603
+ return \`<div class="feat-dot"
25604
+ style="left:\${DATA.todayPct}%;background:\${color}44;border-color:\${color}88"
25605
+ data-key="\${f.key}"
25606
+ onmousemove="showTooltip(event, '\${f.key}')"
25607
+ onmouseleave="hideTooltip()"
25608
+ onclick="window.open('lac-wiki.html#\${f.key}','_self')">
25609
+ </div>\`;
25610
+ }
25611
+ }).join('');
25612
+
25613
+ return \`<div class="lane" style="height:var(--lane-h)">
25614
+ <div class="lane-label"><span class="lane-label-text" title="\${domain}">\${domain}</span></div>
25615
+ <div class="lane-track" style="width:\${trackWidth()}">
25616
+ \${pills}
25617
+ <div class="today-line" style="left:\${DATA.todayPct}%"></div>
25618
+ </div>
25619
+ </div>\`;
25620
+ }).join('');
25621
+ }
25622
+
25623
+ const featByKey = new Map(DATA.features.map(f => [f.key, f]));
25624
+
25625
+ function showTooltip(e, key) {
25626
+ const f = featByKey.get(key);
25627
+ if (!f) return;
25628
+ const color = STATUS_COLOR[f.status] || '#888';
25629
+ const histHtml = f.transitions.length > 0
25630
+ ? '<div class="tooltip-hist">' + f.transitions.map(t =>
25631
+ \`<div class="tooltip-trans">\${t.date} · \${t.from || '–'} → \${t.to}</div>\`
25632
+ ).join('') + '</div>'
25633
+ : '<div class="tooltip-row"><span class="tooltip-label">history</span><span class="tooltip-val" style="color:#4a4540">not recorded</span></div>';
25634
+
25635
+ tooltip.innerHTML =
25636
+ \`<div class="tooltip-title">\${f.title}</div>\` +
25637
+ \`<div class="tooltip-row"><span class="tooltip-label">status</span><span class="tooltip-val" style="color:\${color}">\${f.status}</span></div>\` +
25638
+ \`<div class="tooltip-row"><span class="tooltip-label">domain</span><span class="tooltip-val">\${f.domain}</span></div>\` +
25639
+ \`<div class="tooltip-row"><span class="tooltip-label">key</span><span class="tooltip-val">\${f.key}</span></div>\` +
25640
+ histHtml;
25641
+
25642
+ tooltip.style.left = (e.clientX + 14) + 'px';
25643
+ tooltip.style.top = (e.clientY - 10) + 'px';
25644
+ tooltip.classList.add('visible');
25645
+ }
25646
+ function hideTooltip() { tooltip.classList.remove('visible'); }
25647
+
25648
+ function zoom(factor) {
25649
+ zoomLevel = Math.max(0.5, Math.min(8, zoomLevel * factor));
25650
+ render();
25651
+ }
25652
+ function resetZoom() { zoomLevel = 1; render(); }
25653
+ function setSortMode(mode) { sortMode = mode; render(); }
25654
+
25655
+ render();
25656
+ window.addEventListener('resize', render);
25657
+ <\/script>
25658
+ </body>
25659
+ </html>`;
23467
25660
  }
23468
25661
  //#endregion
23469
25662
  //#region src/commands/export.ts
@@ -23679,7 +25872,7 @@ function buildReconstructionPrompt(features, projectName, promptDir) {
23679
25872
  lines.push("");
23680
25873
  return lines.join("\n");
23681
25874
  }
23682
- const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML view").option("--out <path>", "Output file or directory path").option("--html [dir]", "Scan <dir> (default: cwd) and emit a single self-contained HTML wiki").option("--raw [dir]", "Raw field-by-field HTML dump with sidebar navigation").option("--print [dir]", "Print-ready HTML document (A4, all features, @media print CSS)").option("--postcard", "Beautiful single-feature shareable card (nearest feature.json)").option("--resume [dir]", "Portfolio page from all frozen features").option("--slide [dir]", "Full-screen HTML slideshow, one slide per feature").option("--graph [dir]", "Interactive force-directed feature lineage graph").option("--heatmap [dir]", "Completeness heatmap — fields × features grid").option("--quiz [dir]", "Flashcard-style quiz to test knowledge of your feature set").option("--story [dir]", "Long-form narrative document — product case study from feature data").option("--treemap [dir]", "Rectangular treemap — features sized by decisions × completeness, grouped by domain").option("--kanban [dir]", "Kanban board — Active / Frozen / Draft columns with sortable, filterable cards").option("--health [dir]", "Project health scorecard — completeness, coverage, tech debt, and health score").option("--embed [dir]", "Compact embeddable stats widget (iframe-ready)").option("--decisions [dir]", "Consolidated ADR — all decisions from all features, searchable by domain").option("--guide [dir]", "User guide — one page per feature that has a non-empty userGuide field").option("--hub [dir]", "Hub landing page linking to all generated views → lac-hub.html").option("--all [dir]", "Generate all HTML views + hub index.html → --out dir (default: ./lac-output)").option("--prefix <prefix>", "URL prefix for hub links (no leading slash), e.g. lac → hrefs become /lac/lac-guide.html").option("--diff <dir-b>", "Compare cwd workspace against <dir-b> and show added/removed/changed").option("--site <dir>", "Generate a multi-page static site → --out dir (default: ./lac-site)").option("--prompt [dir]", "AI reconstruction prompt for all features (stdout or --out file)").option("--markdown", "Single feature as Markdown (nearest feature.json)").option("--changelog [dir]", "Structured changelog grouped by month — from revisions[] across all features").option("--since <date>", "Filter --changelog and --release-notes to entries after this date (YYYY-MM-DD)").option("--release-notes [dir]", "User-facing release notes — features that went frozen since --since date or --release version").option("--release <version>", "Filter --release-notes to features matching this releaseVersion (e.g. 3.5.0)").option("--sprint [dir]", "Sprint planning view — draft+active features sorted by priority, summary density").option("--api-surface [dir]", "Aggregated publicInterface[] reference across all features → lac-api-surface.html").option("--dependency-map [dir]", "Runtime dependency graph from externalDependencies[] → lac-depmap.html").option("--tags <tags>", "Comma-separated tags to filter by (OR logic) — applies to all multi-feature modes").option("--sort <mode>", "Sort order for multi-feature modes: key (default) | build-order (parents before children)").option("--view <name>", `Audience view — built-in (${VIEW_NAMES.join(", ")}) or custom name from lac.config.json views`).option("--density <level>", "Content density: summary | standard | verbose (default: standard)").addHelpText("after", `
25875
+ const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML view").option("--out <path>", "Output file or directory path").option("--html [dir]", "Scan <dir> (default: cwd) and emit a single self-contained HTML wiki").option("--raw [dir]", "Raw field-by-field HTML dump with sidebar navigation").option("--print [dir]", "Print-ready HTML document (A4, all features, @media print CSS)").option("--postcard", "Beautiful single-feature shareable card (nearest feature.json)").option("--resume [dir]", "Portfolio page from all frozen features").option("--slide [dir]", "Full-screen HTML slideshow, one slide per feature").option("--graph [dir]", "Interactive force-directed feature lineage graph").option("--heatmap [dir]", "Completeness heatmap — fields × features grid").option("--quiz [dir]", "Flashcard-style quiz to test knowledge of your feature set").option("--story [dir]", "Long-form narrative document — product case study from feature data").option("--treemap [dir]", "Rectangular treemap — features sized by decisions × completeness, grouped by domain").option("--kanban [dir]", "Kanban board — Active / Frozen / Draft columns with sortable, filterable cards").option("--health [dir]", "Project health scorecard — completeness, coverage, tech debt, and health score").option("--embed [dir]", "Compact embeddable stats widget (iframe-ready)").option("--decisions [dir]", "Consolidated ADR — all decisions from all features, searchable by domain").option("--guide [dir]", "User guide — one page per feature that has a non-empty userGuide field").option("--hub [dir]", "Hub landing page linking to all generated views → lac-hub.html").option("--all [dir]", "Generate all HTML views + hub index.html → --out dir (default: ./lac-output)").option("--prefix <prefix>", "URL prefix for hub links (no leading slash), e.g. lac → hrefs become /lac/lac-guide.html").option("--diff <dir-b>", "Compare cwd workspace against <dir-b> and show added/removed/changed").option("--site <dir>", "Generate a multi-page static site → --out dir (default: ./lac-site)").option("--prompt [dir]", "AI reconstruction prompt for all features (stdout or --out file)").option("--markdown", "Single feature as Markdown (nearest feature.json)").option("--changelog [dir]", "Structured changelog grouped by month — from revisions[] across all features").option("--since <date>", "Filter --changelog and --release-notes to entries after this date (YYYY-MM-DD)").option("--release-notes [dir]", "User-facing release notes — features that went frozen since --since date or --release version").option("--release <version>", "Filter --release-notes to features matching this releaseVersion (e.g. 3.5.0)").option("--sprint [dir]", "Sprint planning view — draft+active features sorted by priority, summary density").option("--api-surface [dir]", "Aggregated publicInterface[] reference across all features → lac-api-surface.html").option("--dependency-map [dir]", "Runtime dependency graph from externalDependencies[] → lac-depmap.html").option("--radar [dir]", "Domain maturity radar — SVG polar chart across 5 quality dimensions → lac-radar.html").option("--successboard [dir]", "Success criteria board — achieved/in-progress/planned by successCriteria → lac-successboard.html").option("--pitch [dir]", "Demo slide deck — keyboard-navigable fullscreen presentation → lac-pitch.html").option("--timeline [dir]", "Feature velocity timeline — swim-lane history from statusHistory → lac-timeline.html").option("--data [dir]", "Universal JSON bridge for in-app help/docs — all views per feature → lac-data.json").option("--help-widget [dir]", "Zero-dep vanilla JS help widget + Web Component → lac-help.js").option("--tags <tags>", "Comma-separated tags to filter by (OR logic) — applies to all multi-feature modes").option("--sort <mode>", "Sort order for multi-feature modes: key (default) | build-order (parents before children)").option("--view <name>", `Audience view — built-in (${VIEW_NAMES.join(", ")}) or custom name from lac.config.json views`).option("--density <level>", "Content density: summary | standard | verbose (default: standard)").addHelpText("after", `
23683
25876
  Examples:
23684
25877
  lac export --html HTML wiki (cwd) → lac-wiki.html
23685
25878
  lac export --raw Raw field dump → lac-raw.html
@@ -24224,6 +26417,116 @@ Views (--view):
24224
26417
  }
24225
26418
  return;
24226
26419
  }
26420
+ if (options.radar !== void 0) {
26421
+ const dir = typeof options.radar === "string" ? resolve(options.radar) : resolve(process$1.cwd());
26422
+ const features = await scanAndFilter(dir);
26423
+ if (features.length === 0) {
26424
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
26425
+ process$1.exit(0);
26426
+ }
26427
+ const html = generateRadar(features.map((f) => f.feature), basename(dir));
26428
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-radar.html");
26429
+ try {
26430
+ await writeFile(outFile, html, "utf-8");
26431
+ process$1.stdout.write(`✓ Radar (${features.length} features, ${new Set(features.map((f) => f.feature.domain)).size} domains) → ${options.out ?? "lac-radar.html"}\n`);
26432
+ } catch (err) {
26433
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
26434
+ process$1.exit(1);
26435
+ }
26436
+ return;
26437
+ }
26438
+ if (options.successboard !== void 0) {
26439
+ const dir = typeof options.successboard === "string" ? resolve(options.successboard) : resolve(process$1.cwd());
26440
+ const features = await scanAndFilter(dir);
26441
+ if (features.length === 0) {
26442
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
26443
+ process$1.exit(0);
26444
+ }
26445
+ const fs = features.map((f) => f.feature);
26446
+ const html = generateSuccessboard(fs, basename(dir));
26447
+ const withCriteria = fs.filter((f) => f["successCriteria"] || Array.isArray(f["acceptanceCriteria"])).length;
26448
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-successboard.html");
26449
+ try {
26450
+ await writeFile(outFile, html, "utf-8");
26451
+ process$1.stdout.write(`✓ Success board (${withCriteria}/${features.length} features with criteria) → ${options.out ?? "lac-successboard.html"}\n`);
26452
+ } catch (err) {
26453
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
26454
+ process$1.exit(1);
26455
+ }
26456
+ return;
26457
+ }
26458
+ if (options.pitch !== void 0) {
26459
+ const dir = typeof options.pitch === "string" ? resolve(options.pitch) : resolve(process$1.cwd());
26460
+ const features = await scanAndFilter(dir);
26461
+ if (features.length === 0) {
26462
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
26463
+ process$1.exit(0);
26464
+ }
26465
+ const html = generatePitch(features.map((f) => f.feature), basename(dir));
26466
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-pitch.html");
26467
+ try {
26468
+ await writeFile(outFile, html, "utf-8");
26469
+ process$1.stdout.write(`✓ Pitch deck (${features.length} features) → ${options.out ?? "lac-pitch.html"}\n`);
26470
+ } catch (err) {
26471
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
26472
+ process$1.exit(1);
26473
+ }
26474
+ return;
26475
+ }
26476
+ if (options.timeline !== void 0) {
26477
+ const dir = typeof options.timeline === "string" ? resolve(options.timeline) : resolve(process$1.cwd());
26478
+ const features = await scanAndFilter(dir);
26479
+ if (features.length === 0) {
26480
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
26481
+ process$1.exit(0);
26482
+ }
26483
+ const html = generateTimeline(features.map((f) => f.feature), basename(dir));
26484
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-timeline.html");
26485
+ try {
26486
+ await writeFile(outFile, html, "utf-8");
26487
+ process$1.stdout.write(`✓ Timeline (${features.length} features) → ${options.out ?? "lac-timeline.html"}\n`);
26488
+ } catch (err) {
26489
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
26490
+ process$1.exit(1);
26491
+ }
26492
+ return;
26493
+ }
26494
+ if (options.data !== void 0) {
26495
+ const dir = typeof options.data === "string" ? resolve(options.data) : resolve(process$1.cwd());
26496
+ const features = await scanAndFilter(dir);
26497
+ if (features.length === 0) {
26498
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
26499
+ process$1.exit(0);
26500
+ }
26501
+ const json = generateDataExport(features.map((f) => f.feature), basename(dir), { customViews: config.views });
26502
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-data.json");
26503
+ try {
26504
+ await writeFile(outFile, json, "utf-8");
26505
+ process$1.stdout.write(`✓ Data export (${features.length} features, all views) → ${options.out ?? "lac-data.json"}\n`);
26506
+ } catch (err) {
26507
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
26508
+ process$1.exit(1);
26509
+ }
26510
+ return;
26511
+ }
26512
+ if (options.helpWidget !== void 0) {
26513
+ const dir = typeof options.helpWidget === "string" ? resolve(options.helpWidget) : resolve(process$1.cwd());
26514
+ const features = await scanAndFilter(dir);
26515
+ if (features.length === 0) {
26516
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
26517
+ process$1.exit(0);
26518
+ }
26519
+ const js = generateHelpWidget(features.map((f) => f.feature), basename(dir));
26520
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-help.js");
26521
+ try {
26522
+ await writeFile(outFile, js, "utf-8");
26523
+ process$1.stdout.write(`✓ Help widget (${features.length} features, LacHelp API + Web Component) → ${options.out ?? "lac-help.js"}\n`);
26524
+ } catch (err) {
26525
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
26526
+ process$1.exit(1);
26527
+ }
26528
+ return;
26529
+ }
24227
26530
  if (options.all !== void 0) {
24228
26531
  const dir = typeof options.all === "string" ? resolve(options.all) : resolve(process$1.cwd());
24229
26532
  const outDir = resolve(options.out ?? "./lac-output");
@@ -24265,6 +26568,12 @@ Views (--view):
24265
26568
  await write("lac-sprint.html", generateSprint(fs.filter((f) => f.status === "draft" || f.status === "active"), projectName));
24266
26569
  await write("lac-api-surface.html", generateApiSurface(fs, projectName));
24267
26570
  await write("lac-depmap.html", generateDependencyMap(fs, projectName));
26571
+ await write("lac-radar.html", generateRadar(fs, projectName));
26572
+ await write("lac-successboard.html", generateSuccessboard(fs, projectName));
26573
+ await write("lac-pitch.html", generatePitch(fs, projectName));
26574
+ await write("lac-timeline.html", generateTimeline(fs, projectName));
26575
+ await write("lac-data.json", generateDataExport(fs, projectName, { customViews: config.views }));
26576
+ await write("lac-help.js", generateHelpWidget(fs, projectName));
24268
26577
  const stats = {
24269
26578
  total: fs.length,
24270
26579
  frozen: fs.filter((f) => f.status === "frozen").length,
@@ -24325,7 +26634,7 @@ Views (--view):
24325
26634
  });
24326
26635
  }
24327
26636
  await write("index.html", generateHub(projectName, stats, [...ALL_HUB_ENTRIES, ...customEntries], (/* @__PURE__ */ new Date()).toISOString(), options.prefix));
24328
- const totalFiles = 15 + customEntries.length + 1;
26637
+ const totalFiles = 21 + customEntries.length + 1;
24329
26638
  process$1.stdout.write(`Done — ${features.length} features, ${totalFiles} files written to ${outDir}\n`);
24330
26639
  return;
24331
26640
  }