@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 +1122 -465
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
-
|
|
25434
|
+
.timeline-wrap { flex: 1; overflow: hidden; position: relative; display: flex; flex-direction: column; }
|
|
24768
25435
|
|
|
24769
|
-
|
|
24770
|
-
|
|
24771
|
-
|
|
24772
|
-
|
|
24773
|
-
|
|
24774
|
-
|
|
24775
|
-
|
|
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
|
-
|
|
24780
|
-
|
|
24781
|
-
|
|
24782
|
-
|
|
24783
|
-
|
|
24784
|
-
|
|
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
|
-
|
|
24787
|
-
|
|
24788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24793
|
-
|
|
24794
|
-
|
|
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
|
-
|
|
24800
|
-
|
|
24801
|
-
|
|
24802
|
-
|
|
24803
|
-
|
|
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
|
-
|
|
24806
|
-
|
|
24807
|
-
|
|
24808
|
-
|
|
24809
|
-
|
|
24810
|
-
|
|
24811
|
-
|
|
24812
|
-
|
|
24813
|
-
|
|
24814
|
-
|
|
24815
|
-
|
|
24816
|
-
|
|
24817
|
-
|
|
24818
|
-
|
|
24819
|
-
|
|
24820
|
-
|
|
24821
|
-
|
|
24822
|
-
|
|
24823
|
-
|
|
24824
|
-
|
|
24825
|
-
|
|
24826
|
-
|
|
24827
|
-
|
|
24828
|
-
|
|
24829
|
-
|
|
24830
|
-
|
|
24831
|
-
|
|
24832
|
-
|
|
24833
|
-
|
|
24834
|
-
|
|
24835
|
-
|
|
24836
|
-
|
|
24837
|
-
|
|
24838
|
-
|
|
24839
|
-
|
|
24840
|
-
|
|
24841
|
-
|
|
24842
|
-
|
|
24843
|
-
|
|
24844
|
-
|
|
24845
|
-
|
|
24846
|
-
|
|
24847
|
-
|
|
24848
|
-
|
|
24849
|
-
|
|
24850
|
-
|
|
24851
|
-
|
|
24852
|
-
|
|
24853
|
-
|
|
24854
|
-
|
|
24855
|
-
|
|
24856
|
-
|
|
24857
|
-
|
|
24858
|
-
|
|
24859
|
-
|
|
24860
|
-
|
|
24861
|
-
|
|
24862
|
-
|
|
24863
|
-
|
|
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
|
-
|
|
24955
|
-
|
|
24956
|
-
|
|
24957
|
-
|
|
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
|
-
|
|
24976
|
-
|
|
24977
|
-
|
|
24978
|
-
|
|
24979
|
-
|
|
24980
|
-
|
|
24981
|
-
|
|
24982
|
-
|
|
24983
|
-
|
|
24984
|
-
|
|
24985
|
-
|
|
24986
|
-
|
|
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
|
-
|
|
24994
|
-
|
|
24995
|
-
|
|
24996
|
-
|
|
24997
|
-
|
|
24998
|
-
|
|
24999
|
-
|
|
25000
|
-
|
|
25001
|
-
|
|
25002
|
-
|
|
25003
|
-
|
|
25004
|
-
|
|
25005
|
-
|
|
25006
|
-
|
|
25007
|
-
|
|
25008
|
-
|
|
25009
|
-
|
|
25010
|
-
|
|
25011
|
-
|
|
25012
|
-
|
|
25013
|
-
|
|
25014
|
-
|
|
25015
|
-
|
|
25016
|
-
|
|
25017
|
-
|
|
25018
|
-
|
|
25019
|
-
|
|
25020
|
-
|
|
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
|
-
|
|
25024
|
-
|
|
25025
|
-
|
|
25026
|
-
|
|
25027
|
-
|
|
25028
|
-
|
|
25029
|
-
|
|
25030
|
-
|
|
25031
|
-
|
|
25032
|
-
|
|
25033
|
-
|
|
25034
|
-
|
|
25035
|
-
|
|
25036
|
-
|
|
25037
|
-
|
|
25038
|
-
|
|
25039
|
-
|
|
25040
|
-
|
|
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 =
|
|
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
|
}
|