@majeanson/lac 3.5.2 → 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
@@ -22919,6 +22919,848 @@ loop();
22919
22919
  </html>`;
22920
22920
  }
22921
22921
  //#endregion
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
+ ])
22959
+ },
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
+ ])
22972
+ },
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
+ ])
22992
+ },
22993
+ dev: {
22994
+ name: "dev",
22995
+ label: "Developer",
22996
+ description: "Full implementation context — code, 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
+ ])
23020
+ },
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
+ ])
23058
+ }
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
22922
23764
  //#region src/lib/hubGenerator.ts
22923
23765
  /** Canonical ordered entry definitions for all standard LAC outputs. */
22924
23766
  const ALL_HUB_ENTRIES = [
@@ -23054,6 +23896,20 @@ const ALL_HUB_ENTRIES = [
23054
23896
  description: "Horizontal swim-lane timeline built from statusHistory — velocity at a glance",
23055
23897
  icon: "📆",
23056
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
23057
23913
  }
23058
23914
  ];
23059
23915
  function generateHub(projectName, stats, entries, generatedAt = (/* @__PURE__ */ new Date()).toISOString(), prefix) {
@@ -24562,482 +25418,245 @@ html, body { height: 100%; background: var(--bg); color: var(--text); font-famil
24562
25418
  background: var(--bg-card); border-bottom: 1px solid var(--border); flex-shrink: 0;
24563
25419
  font-size: 12px;
24564
25420
  }
24565
- .ctrl-label { color: var(--text-soft); font-family: var(--mono); }
24566
- .ctrl-btn {
24567
- padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border);
24568
- background: transparent; color: var(--text-soft); cursor: pointer; font-size: 12px; font-family: var(--mono);
24569
- transition: all .15s;
24570
- }
24571
- .ctrl-btn:hover { border-color: var(--accent); color: var(--accent); }
24572
- .ctrl-sep { color: var(--border); }
24573
- .legend { display: flex; gap: 14px; margin-left: auto; }
24574
- .leg-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text-soft); }
24575
- .leg-dot { width: 10px; height: 10px; border-radius: 2px; }
24576
- .zoom-info { font-size: 11px; color: var(--text-soft); font-family: var(--mono); }
24577
-
24578
- .timeline-wrap { flex: 1; overflow: hidden; position: relative; display: flex; flex-direction: column; }
24579
-
24580
- /* Tick labels row */
24581
- .tick-row {
24582
- display: flex; height: 32px; flex-shrink: 0;
24583
- padding-left: var(--label-w); position: relative; overflow: hidden;
24584
- border-bottom: 1px solid var(--border);
24585
- }
24586
- .tick-track { position: absolute; left: var(--label-w); right: 0; top: 0; bottom: 0; }
24587
-
24588
- /* Lanes */
24589
- .lanes-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; position: relative; }
24590
- .lanes-inner { position: relative; }
24591
- .lane { display: flex; height: var(--lane-h); border-bottom: 1px solid var(--border-soft); }
24592
- .lane:hover { background: rgba(255,255,255,.012); }
24593
- .lane-label {
24594
- width: var(--label-w); flex-shrink: 0; padding: 0 12px 0 16px;
24595
- display: flex; align-items: center; border-right: 1px solid var(--border);
24596
- position: sticky; left: 0; background: var(--bg); z-index: 2;
24597
- }
24598
- .lane-label-text { font-size: 11px; color: var(--text-soft); font-family: var(--mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
24599
- .lane-track { flex: 1; position: relative; }
24600
-
24601
- /* Feature pill */
24602
- .feat-pill {
24603
- position: absolute; top: 8px; height: 34px;
24604
- border-radius: 5px; cursor: pointer;
24605
- display: flex; align-items: center; padding: 0 6px;
24606
- transition: filter .15s, z-index .15s;
24607
- border: 1px solid rgba(255,255,255,.08);
24608
- min-width: 6px; overflow: hidden;
24609
- }
24610
- .feat-pill:hover { filter: brightness(1.3); z-index: 10; }
24611
- .feat-pill-text { font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: rgba(255,255,255,.85); font-family: var(--mono); }
24612
-
24613
- /* Dot for features without history */
24614
- .feat-dot {
24615
- position: absolute; top: 50%; width: 8px; height: 8px;
24616
- border-radius: 50%; transform: translate(-50%, -50%);
24617
- cursor: pointer; border: 1px solid rgba(255,255,255,.2);
24618
- }
24619
-
24620
- /* Today line */
24621
- .today-line { position: absolute; top: 0; bottom: 0; width: 1.5px; background: rgba(212,168,83,.5); pointer-events: none; z-index: 5; }
24622
- .today-label { position: absolute; top: 4px; font-size: 9px; color: var(--accent); font-family: var(--mono); transform: translateX(-50%); white-space: nowrap; }
24623
-
24624
- /* Tooltip */
24625
- .tooltip {
24626
- position: fixed; pointer-events: none; z-index: 999;
24627
- background: #1e1b18; border: 1px solid var(--border); border-radius: 8px;
24628
- padding: 12px 14px; font-size: 12px; color: var(--text); max-width: 260px;
24629
- display: none; box-shadow: 0 8px 24px rgba(0,0,0,.5);
24630
- }
24631
- .tooltip.visible { display: block; }
24632
- .tooltip-title { font-weight: 600; margin-bottom: 6px; font-size: 13px; line-height: 1.3; }
24633
- .tooltip-row { display: flex; justify-content: space-between; gap: 20px; margin: 2px 0; font-size: 11px; }
24634
- .tooltip-label { color: var(--text-soft); }
24635
- .tooltip-val { font-family: var(--mono); }
24636
- .tooltip-hist { margin-top: 8px; border-top: 1px solid var(--border); padding-top: 8px; }
24637
- .tooltip-trans { font-size: 10px; color: var(--text-soft); font-family: var(--mono); margin: 1px 0; }
24638
- </style>
24639
- </head>
24640
- <body>
24641
- <div class="topbar">
24642
- <span class="topbar-logo">◈ lac</span>
24643
- <span class="topbar-sep">|</span>
24644
- <span class="topbar-project">${esc$1(projectName)} — Feature Timeline</span>
24645
- <span class="topbar-count">${features.length} features · ${featuresWithHistory} with history · ${featuresWithoutHistory} undated</span>
24646
- </div>
24647
-
24648
- <div class="controls">
24649
- <span class="ctrl-label">Zoom:</span>
24650
- <button class="ctrl-btn" onclick="zoom(0.7)">−</button>
24651
- <span class="zoom-info" id="zoom-info">100%</span>
24652
- <button class="ctrl-btn" onclick="zoom(1.4)">+</button>
24653
- <button class="ctrl-btn" onclick="resetZoom()">Reset</button>
24654
- <span class="ctrl-sep">|</span>
24655
- <span class="ctrl-label">Sort:</span>
24656
- <button class="ctrl-btn" onclick="setSortMode('domain')">Domain</button>
24657
- <button class="ctrl-btn" onclick="setSortMode('start')">Start date</button>
24658
- <button class="ctrl-btn" onclick="setSortMode('status')">Status</button>
24659
- <div class="legend">
24660
- ${Object.entries(STATUS_COLOR).map(([s, c]) => `<div class="leg-item"><div class="leg-dot" style="background:${c}"></div>${s}</div>`).join("")}
24661
- <div class="leg-item"><div class="leg-dot" style="background:#3a3530;border:1px solid #5a5550"></div>no history</div>
24662
- </div>
24663
- </div>
24664
-
24665
- <div class="timeline-wrap">
24666
- <div class="tick-row">
24667
- <div class="tick-track" id="tick-track"></div>
24668
- </div>
24669
- <div class="lanes-scroll">
24670
- <div class="lanes-inner" id="lanes"></div>
24671
- </div>
24672
- </div>
24673
-
24674
- <div class="tooltip" id="tooltip"></div>
24675
-
24676
- <script>
24677
- const DATA = ${dataJson};
24678
- const STATUS_COLOR = ${statusColorJson};
24679
-
24680
- let zoomLevel = 1;
24681
- let sortMode = 'domain';
24682
- const tooltip = document.getElementById('tooltip');
24683
-
24684
- // Scale: zoomLevel * 100% width for the track
24685
- function trackWidth() { return Math.round(zoomLevel * 100) + '%'; }
24686
-
24687
- function pctToPx(pct) {
24688
- const track = document.getElementById('lanes');
24689
- return (pct / 100) * track.offsetWidth;
24690
- }
24691
-
24692
- function render() {
24693
- document.getElementById('zoom-info').textContent = Math.round(zoomLevel * 100) + '%';
24694
- renderTicks();
24695
- renderLanes();
24696
- }
24697
-
24698
- function renderTicks() {
24699
- const track = document.getElementById('tick-track');
24700
- track.style.width = trackWidth();
24701
- track.innerHTML = DATA.ticks.map(t =>
24702
- t.pct >= 0 && t.pct <= 100
24703
- ? \`<div style="position:absolute;left:\${t.pct}%;top:0;bottom:0;border-left:1px solid #2a2724;padding-top:8px">
24704
- <span style="font-size:9px;color:#6a6055;font-family:var(--mono);padding-left:4px">\${t.label}</span>
24705
- </div>\`
24706
- : ''
24707
- ).join('') + \`<div class="today-line" style="left:\${DATA.todayPct}%">
24708
- <div class="today-label" style="left:50%">today</div>
24709
- </div>\`;
24710
- }
24711
-
24712
- function renderLanes() {
24713
- const lanesEl = document.getElementById('lanes');
24714
- // Sort domains
24715
- let domainOrder = [...DATA.domains];
24716
- if (sortMode === 'status') {
24717
- // sort domains by avg frozen ratio descending
24718
- }
24719
-
24720
- lanesEl.innerHTML = domainOrder.map(domain => {
24721
- const domFeats = DATA.features.filter(f => f.domain === domain);
24722
- // Sort features within domain
24723
- let sorted = [...domFeats];
24724
- if (sortMode === 'start') sorted.sort((a,b) => (a.startPct??101) - (b.startPct??101));
24725
- else if (sortMode === 'status') sorted.sort((a,b) => a.status.localeCompare(b.status));
24726
- else sorted.sort((a,b) => (a.startPct??101) - (b.startPct??101));
24727
-
24728
- const laneH = Math.max(${DOMAIN_ORDER.length > 0 ? "Math.ceil(sorted.length / 1) * 44 + 8" : "52"}, 52);
24729
-
24730
- const pills = sorted.map((f, fi) => {
24731
- const color = STATUS_COLOR[f.status] || '#444';
24732
- const bgAlpha = f.hasHistory ? '55' : '22';
24733
- const top = 8 + Math.floor(fi * 0) ; // stack vertically — one row per feature lane
24734
- if (f.startPct !== null && f.endPct !== null) {
24735
- const left = Math.max(0, f.startPct);
24736
- const width = Math.max(0.4, f.endPct - f.startPct);
24737
- return \`<div class="feat-pill"
24738
- style="left:\${left}%;width:\${width}%;background:\${color}\${bgAlpha};top:8px"
24739
- data-key="\${f.key}"
24740
- onmousemove="showTooltip(event, '\${f.key}')"
24741
- onmouseleave="hideTooltip()"
24742
- onclick="window.open('lac-wiki.html#\${f.key}','_self')">
24743
- \${width > 2 ? \`<span class="feat-pill-text">\${f.title}</span>\` : ''}
24744
- </div>\`;
24745
- } else {
24746
- // dot at end of timeline
24747
- return \`<div class="feat-dot"
24748
- style="left:\${DATA.todayPct}%;background:\${color}44;border-color:\${color}88"
24749
- data-key="\${f.key}"
24750
- onmousemove="showTooltip(event, '\${f.key}')"
24751
- onmouseleave="hideTooltip()"
24752
- onclick="window.open('lac-wiki.html#\${f.key}','_self')">
24753
- </div>\`;
24754
- }
24755
- }).join('');
24756
-
24757
- return \`<div class="lane" style="height:var(--lane-h)">
24758
- <div class="lane-label"><span class="lane-label-text" title="\${domain}">\${domain}</span></div>
24759
- <div class="lane-track" style="width:\${trackWidth()}">
24760
- \${pills}
24761
- <div class="today-line" style="left:\${DATA.todayPct}%"></div>
24762
- </div>
24763
- </div>\`;
24764
- }).join('');
24765
- }
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); }
24766
25433
 
24767
- const featByKey = new Map(DATA.features.map(f => [f.key, f]));
25434
+ .timeline-wrap { flex: 1; overflow: hidden; position: relative; display: flex; flex-direction: column; }
24768
25435
 
24769
- function showTooltip(e, key) {
24770
- const f = featByKey.get(key);
24771
- if (!f) return;
24772
- const color = STATUS_COLOR[f.status] || '#888';
24773
- const histHtml = f.transitions.length > 0
24774
- ? '<div class="tooltip-hist">' + f.transitions.map(t =>
24775
- \`<div class="tooltip-trans">\${t.date} · \${t.from || '–'} \${t.to}</div>\`
24776
- ).join('') + '</div>'
24777
- : '<div class="tooltip-row"><span class="tooltip-label">history</span><span class="tooltip-val" style="color:#4a4540">not recorded</span></div>';
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; }
24778
25443
 
24779
- tooltip.innerHTML =
24780
- \`<div class="tooltip-title">\${f.title}</div>\` +
24781
- \`<div class="tooltip-row"><span class="tooltip-label">status</span><span class="tooltip-val" style="color:\${color}">\${f.status}</span></div>\` +
24782
- \`<div class="tooltip-row"><span class="tooltip-label">domain</span><span class="tooltip-val">\${f.domain}</span></div>\` +
24783
- \`<div class="tooltip-row"><span class="tooltip-label">key</span><span class="tooltip-val">\${f.key}</span></div>\` +
24784
- histHtml;
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; }
24785
25456
 
24786
- tooltip.style.left = (e.clientX + 14) + 'px';
24787
- tooltip.style.top = (e.clientY - 10) + 'px';
24788
- tooltip.classList.add('visible');
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;
24789
25465
  }
24790
- function hideTooltip() { tooltip.classList.remove('visible'); }
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); }
24791
25468
 
24792
- function zoom(factor) {
24793
- zoomLevel = Math.max(0.5, Math.min(8, zoomLevel * factor));
24794
- render();
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);
24795
25474
  }
24796
- function resetZoom() { zoomLevel = 1; render(); }
24797
- function setSortMode(mode) { sortMode = mode; render(); }
24798
25475
 
24799
- render();
24800
- window.addEventListener('resize', render);
24801
- <\/script>
24802
- </body>
24803
- </html>`;
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);
24804
25486
  }
24805
- //#endregion
24806
- //#region src/lib/views.ts
24807
- /** Fields always shown at summary density regardless of view */
24808
- const SUMMARY_FIELDS = new Set([
24809
- "featureKey",
24810
- "title",
24811
- "status",
24812
- "domain",
24813
- "priority",
24814
- "tags",
24815
- "problem"
24816
- ]);
24817
- const VIEW_NAMES = [
24818
- "dev",
24819
- "product",
24820
- "user",
24821
- "support",
24822
- "tech"
24823
- ];
24824
- /** Always-present identity fields included in every view */
24825
- const IDENTITY = [
24826
- "featureKey",
24827
- "title",
24828
- "status",
24829
- "domain"
24830
- ];
24831
- const VIEWS = {
24832
- user: {
24833
- name: "user",
24834
- label: "User",
24835
- description: "Plain-language guide what the feature does and why it exists",
24836
- fields: new Set([
24837
- "title",
24838
- "problem",
24839
- "userGuide",
24840
- "successCriteria",
24841
- "tags"
24842
- ])
24843
- },
24844
- support: {
24845
- name: "support",
24846
- label: "Support",
24847
- description: "Known limitations, annotations, and escalation context for support teams",
24848
- fields: new Set([
24849
- ...IDENTITY,
24850
- "owner",
24851
- "problem",
24852
- "knownLimitations",
24853
- "annotations",
24854
- "tags"
24855
- ])
24856
- },
24857
- product: {
24858
- name: "product",
24859
- label: "Product",
24860
- description: "Business problem, success criteria, and strategic decisions — no implementation details",
24861
- fields: new Set([
24862
- ...IDENTITY,
24863
- "owner",
24864
- "priority",
24865
- "problem",
24866
- "analysis",
24867
- "userGuide",
24868
- "pmSummary",
24869
- "successCriteria",
24870
- "acceptanceCriteria",
24871
- "decisions",
24872
- "knownLimitations",
24873
- "tags",
24874
- "releaseVersion"
24875
- ])
24876
- },
24877
- dev: {
24878
- name: "dev",
24879
- label: "Developer",
24880
- description: "Full implementation context — code, decisions, snippets, and lineage",
24881
- fields: new Set([
24882
- ...IDENTITY,
24883
- "owner",
24884
- "priority",
24885
- "problem",
24886
- "analysis",
24887
- "implementation",
24888
- "implementationNotes",
24889
- "userGuide",
24890
- "successCriteria",
24891
- "acceptanceCriteria",
24892
- "testStrategy",
24893
- "decisions",
24894
- "knownLimitations",
24895
- "tags",
24896
- "annotations",
24897
- "lineage",
24898
- "componentFile",
24899
- "npmPackages",
24900
- "publicInterface",
24901
- "externalDependencies",
24902
- "codeSnippets"
24903
- ])
24904
- },
24905
- tech: {
24906
- name: "tech",
24907
- label: "Technical",
24908
- description: "Complete technical record — all fields including history, revisions, and lineage",
24909
- fields: new Set([
24910
- ...IDENTITY,
24911
- "schemaVersion",
24912
- "owner",
24913
- "priority",
24914
- "problem",
24915
- "analysis",
24916
- "implementation",
24917
- "implementationNotes",
24918
- "userGuide",
24919
- "pmSummary",
24920
- "successCriteria",
24921
- "acceptanceCriteria",
24922
- "testStrategy",
24923
- "decisions",
24924
- "knownLimitations",
24925
- "tags",
24926
- "annotations",
24927
- "lineage",
24928
- "statusHistory",
24929
- "revisions",
24930
- "componentFile",
24931
- "npmPackages",
24932
- "publicInterface",
24933
- "externalDependencies",
24934
- "codeSnippets",
24935
- "lastVerifiedDate",
24936
- "releaseVersion",
24937
- "superseded_by",
24938
- "superseded_from",
24939
- "merged_into",
24940
- "merged_from"
24941
- ])
24942
- }
24943
- };
24944
- /**
24945
- * Return a copy of `feature` with only the keys allowed by `view`.
24946
- * Fields not in the view's set are omitted entirely.
24947
- */
24948
- function applyView(feature, view) {
24949
- const result = {};
24950
- for (const key of Object.keys(feature)) if (view.fields.has(key)) result[key] = feature[key];
24951
- return result;
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;
24952
25546
  }
24953
- /**
24954
- * Fields the HTML wiki renderer requires for sidebar navigation and routing.
24955
- * These are always preserved regardless of view, so the wiki remains navigable.
24956
- */
24957
- const HTML_NAV_FIELDS = new Set([
24958
- "featureKey",
24959
- "title",
24960
- "status",
24961
- "domain",
24962
- "lineage",
24963
- "priority"
24964
- ]);
24965
- /**
24966
- * Like `applyView`, but always preserves HTML navigation fields so the wiki
24967
- * sidebar and routing continue to work correctly.
24968
- */
24969
- function applyViewForHtml(feature, view) {
24970
- const result = {};
24971
- for (const key of Object.keys(feature)) if (view.fields.has(key) || HTML_NAV_FIELDS.has(key)) result[key] = feature[key];
24972
- return result;
25547
+
25548
+ function render() {
25549
+ document.getElementById('zoom-info').textContent = Math.round(zoomLevel * 100) + '%';
25550
+ renderTicks();
25551
+ renderLanes();
24973
25552
  }
24974
- /**
24975
- * Apply density filtering to a feature.
24976
- * - summary: only SUMMARY_FIELDS (title, status, domain, priority, tags, problem snippet)
24977
- * - standard: pass through unchanged (generators decide what to show)
24978
- * - verbose: pass through unchanged but callers should render VERBOSE_EXTRA_FIELDS too
24979
- *
24980
- * Returns the filtered feature and the resolved density level.
24981
- */
24982
- function applyDensity(feature, density) {
24983
- if (density === "standard" || density === "verbose") return feature;
24984
- const result = {};
24985
- for (const key of Object.keys(feature)) if (SUMMARY_FIELDS.has(key)) if (key === "problem" && typeof feature[key] === "string") {
24986
- const prob = feature[key];
24987
- const firstSentence = prob.split(/[.!?]\s/)[0] ?? prob;
24988
- result[key] = firstSentence.length < prob.length ? firstSentence + "." : prob;
24989
- } else result[key] = feature[key];
24990
- return result;
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>\`;
24991
25566
  }
24992
- /**
24993
- * Resolve a view name against both built-in views and custom views from lac.config.json.
24994
- *
24995
- * Resolution order:
24996
- * 1. If `name` matches a key in `customViews` (from lac.config.json), build a ViewConfig from it.
24997
- * If it has `extends`, merge on top of the built-in base.
24998
- * 2. Otherwise fall back to VIEWS[name].
24999
- * 3. If neither matches, return undefined.
25000
- */
25001
- function resolveView(name, customViews = {}) {
25002
- const custom = customViews[name];
25003
- if (custom) {
25004
- const base = custom.extends ? VIEWS[custom.extends] : void 0;
25005
- const baseFields = base ? new Set(base.fields) : /* @__PURE__ */ new Set();
25006
- const fields = custom.fields ? new Set(custom.fields) : baseFields;
25007
- for (const f of IDENTITY) fields.add(f);
25008
- return {
25009
- name,
25010
- label: custom.label ?? base?.label ?? name,
25011
- description: custom.description ?? base?.description ?? `Custom view: ${name}`,
25012
- fields,
25013
- density: custom.density,
25014
- groupBy: custom.groupBy,
25015
- sortBy: custom.sortBy,
25016
- filterStatus: custom.filterStatus,
25017
- sections: custom.sections
25018
- };
25019
- }
25020
- return VIEW_NAMES.includes(name) ? VIEWS[name] : void 0;
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('');
25021
25621
  }
25022
- /**
25023
- * Sort and filter a feature list according to a resolved view profile.
25024
- * This is called before passing features to any generator.
25025
- */
25026
- function applyViewTransforms(features, profile) {
25027
- let result = [...features];
25028
- if (profile.filterStatus && profile.filterStatus.length > 0) result = result.filter((f) => profile.filterStatus.includes(f["status"]));
25029
- if (profile.sortBy === "priority") result.sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
25030
- else if (profile.sortBy === "title") result.sort((a, b) => String(a["title"] ?? "").localeCompare(String(b["title"] ?? "")));
25031
- else if (profile.sortBy === "status") {
25032
- const order = {
25033
- active: 0,
25034
- draft: 1,
25035
- frozen: 2,
25036
- deprecated: 3
25037
- };
25038
- result.sort((a, b) => (order[a["status"]] ?? 9) - (order[b["status"]] ?? 9));
25039
- } else if (profile.sortBy === "lastVerifiedDate") result.sort((a, b) => String(b["lastVerifiedDate"] ?? "").localeCompare(String(a["lastVerifiedDate"] ?? "")));
25040
- return result;
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>`;
25041
25660
  }
25042
25661
  //#endregion
25043
25662
  //#region src/commands/export.ts
@@ -25253,7 +25872,7 @@ function buildReconstructionPrompt(features, projectName, promptDir) {
25253
25872
  lines.push("");
25254
25873
  return lines.join("\n");
25255
25874
  }
25256
- 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("--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", `
25257
25876
  Examples:
25258
25877
  lac export --html HTML wiki (cwd) → lac-wiki.html
25259
25878
  lac export --raw Raw field dump → lac-raw.html
@@ -25872,6 +26491,42 @@ Views (--view):
25872
26491
  }
25873
26492
  return;
25874
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
+ }
25875
26530
  if (options.all !== void 0) {
25876
26531
  const dir = typeof options.all === "string" ? resolve(options.all) : resolve(process$1.cwd());
25877
26532
  const outDir = resolve(options.out ?? "./lac-output");
@@ -25917,6 +26572,8 @@ Views (--view):
25917
26572
  await write("lac-successboard.html", generateSuccessboard(fs, projectName));
25918
26573
  await write("lac-pitch.html", generatePitch(fs, projectName));
25919
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));
25920
26577
  const stats = {
25921
26578
  total: fs.length,
25922
26579
  frozen: fs.filter((f) => f.status === "frozen").length,
@@ -25977,7 +26634,7 @@ Views (--view):
25977
26634
  });
25978
26635
  }
25979
26636
  await write("index.html", generateHub(projectName, stats, [...ALL_HUB_ENTRIES, ...customEntries], (/* @__PURE__ */ new Date()).toISOString(), options.prefix));
25980
- const totalFiles = 19 + customEntries.length + 1;
26637
+ const totalFiles = 21 + customEntries.length + 1;
25981
26638
  process$1.stdout.write(`Done — ${features.length} features, ${totalFiles} files written to ${outDir}\n`);
25982
26639
  return;
25983
26640
  }