@hobocode/thought-layer 0.6.1 → 0.8.5
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/README.md +7 -3
- package/core/artifacts-io.ts +146 -0
- package/core/artifacts.ts +690 -0
- package/core/index.ts +7 -0
- package/core/merge.ts +157 -0
- package/core/notion-io.ts +292 -0
- package/core/notion.ts +312 -0
- package/core/progress.ts +8 -5
- package/core/state-ops.ts +1 -1
- package/core/sync-io.ts +432 -0
- package/core/sync.ts +150 -0
- package/dist/tl.js +1866 -45
- package/extensions/thought-layer.ts +93 -2
- package/package.json +8 -1
- package/prompts/tl-artifacts.md +7 -0
- package/prompts/tl-compliance.md +7 -0
- package/prompts/tl-wiki.md +9 -0
- package/skills/thought-layer-compliance/SKILL.md +139 -0
- package/skills/thought-layer-framework/SKILL.md +9 -0
- package/skills/thought-layer-wiki/SKILL.md +43 -0
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
// Pure artifact generation: from a ProgressState, produce the full "startup kit"
|
|
2
|
+
// of deliverables as an in-memory file map (path -> text), mirroring the web
|
|
3
|
+
// app's src/lib/exports.js. No node:, no fetch, no DOM - the artifacts-io layer
|
|
4
|
+
// writes the map to disk and delivers it to GitHub, and the notion layer reads
|
|
5
|
+
// the manifest to lay out the wiki.
|
|
6
|
+
//
|
|
7
|
+
// The pure markdown generators are ported dash-free (the kit's copy rule: no
|
|
8
|
+
// em-dash, en-dash, or spaced hyphen in generated prose, see backend.ts). The
|
|
9
|
+
// HTML look book and the SVG infographics are ported faithfully (they are markup
|
|
10
|
+
// + visual copy, exactly as scaffold.ts emits HTML); the dash-free unit test
|
|
11
|
+
// covers only the markdown outputs.
|
|
12
|
+
//
|
|
13
|
+
// The heavy Office binaries (.pptx deck, .xlsx workbook) are intentionally NOT
|
|
14
|
+
// here: they need large non-pure deps and their content already ships as an SVG
|
|
15
|
+
// infographic + markdown. See the plan's Phase 3.
|
|
16
|
+
|
|
17
|
+
import { computeProjection, fmtMoney, type Assumptions } from "./model.ts";
|
|
18
|
+
import { extractScaffoldSpec, buildStarterSite } from "./scaffold.ts";
|
|
19
|
+
import type { ProgressState } from "./progress.ts";
|
|
20
|
+
|
|
21
|
+
// ---- consumed state shapes (permissive; everything optional) -----------------
|
|
22
|
+
|
|
23
|
+
export interface BrandGuide {
|
|
24
|
+
brandName?: string;
|
|
25
|
+
tagline?: string;
|
|
26
|
+
positioning?: string;
|
|
27
|
+
personality?: string[];
|
|
28
|
+
voice?: { tone?: string; dos?: string[]; donts?: string[] };
|
|
29
|
+
palette?: Array<{ name?: string; hex?: string; role?: string }>;
|
|
30
|
+
typography?: {
|
|
31
|
+
display?: { family?: string; weights?: string; usage?: string };
|
|
32
|
+
body?: { family?: string; weights?: string; usage?: string };
|
|
33
|
+
};
|
|
34
|
+
logoDirection?: string;
|
|
35
|
+
imagery?: string;
|
|
36
|
+
messaging?: string[];
|
|
37
|
+
}
|
|
38
|
+
export interface Brand {
|
|
39
|
+
guide?: BrandGuide;
|
|
40
|
+
logos?: Array<{ id?: string; label?: string; rationale?: string; svg?: string }>;
|
|
41
|
+
chosenLogoId?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface Grill {
|
|
44
|
+
glossary?: Array<{ term?: string; definition?: string }>;
|
|
45
|
+
requirements?: Array<{ id?: string; category?: string; text?: string; statement?: string }>;
|
|
46
|
+
}
|
|
47
|
+
export interface Swot {
|
|
48
|
+
strengths?: string[];
|
|
49
|
+
weaknesses?: string[];
|
|
50
|
+
opportunities?: string[];
|
|
51
|
+
threats?: string[];
|
|
52
|
+
}
|
|
53
|
+
export interface Governance {
|
|
54
|
+
jurisdiction?: string;
|
|
55
|
+
entityType?: string;
|
|
56
|
+
sector?: string;
|
|
57
|
+
report?: string; // the researched GRC + licensing + tax markdown report
|
|
58
|
+
sources?: string[];
|
|
59
|
+
generatedAt?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- small narrow helpers (mirror scaffold.ts) -------------------------------
|
|
63
|
+
|
|
64
|
+
const obj = (v: unknown): Record<string, unknown> =>
|
|
65
|
+
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
66
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
67
|
+
const esc = (s: unknown): string =>
|
|
68
|
+
String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
69
|
+
const fam = (f: string): string => String(f).trim().replace(/\s+/g, "+");
|
|
70
|
+
|
|
71
|
+
// ---- brand tokens + svg font block (ported from exports.js) ------------------
|
|
72
|
+
|
|
73
|
+
export interface BrandTokens {
|
|
74
|
+
primary: string;
|
|
75
|
+
accent: string;
|
|
76
|
+
ink: string;
|
|
77
|
+
surface: string;
|
|
78
|
+
muted: string;
|
|
79
|
+
display: string;
|
|
80
|
+
body: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function brandTokens(guide: BrandGuide | null | undefined): BrandTokens | null {
|
|
84
|
+
if (!guide || !Array.isArray(guide.palette) || !guide.palette.length) return null;
|
|
85
|
+
const role = (re: string, fb: string): string =>
|
|
86
|
+
guide.palette!.find((p) => p?.name && new RegExp(re, "i").test(p.name))?.hex || fb;
|
|
87
|
+
return {
|
|
88
|
+
primary: role("primary", guide.palette[0]?.hex || "#1f3a5f"),
|
|
89
|
+
accent: role("accent|secondary", guide.palette[1]?.hex || "#e8743b"),
|
|
90
|
+
ink: role("ink|text|dark|black", "#16202b"),
|
|
91
|
+
surface: role("surface|background|light|paper|off.?white|cream", "#f7f8fa"),
|
|
92
|
+
muted: role("muted|gray|grey|neutral|border", "#8a9099"),
|
|
93
|
+
display: guide.typography?.display?.family || "Inter",
|
|
94
|
+
body: guide.typography?.body?.family || "Inter",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const SVG_FONTS = `<style><![CDATA[@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500&display=swap');.s{font-family:'Inter',system-ui,sans-serif}.d{font-family:'Inter',system-ui,sans-serif}.m{font-family:'IBM Plex Mono',ui-monospace,monospace}]]></style>`;
|
|
99
|
+
|
|
100
|
+
function svgFontBlock(t: BrandTokens | null): { style: string; disp: string } {
|
|
101
|
+
if (!t) return { style: SVG_FONTS, disp: "d" };
|
|
102
|
+
const style = `<style><![CDATA[@import url('https://fonts.googleapis.com/css2?family=${fam(t.display)}:wght@600;700;800&family=${fam(t.body)}:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');.s{font-family:'${t.body}',system-ui,sans-serif}.d{font-family:'${t.display}','${t.body}',sans-serif}.m{font-family:'IBM Plex Mono',ui-monospace,monospace}]]></style>`;
|
|
103
|
+
return { style, disp: "d" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Greedy word-wrap for SVG text (SVG never auto-wraps). Estimates Inter advance
|
|
107
|
+
// at ~0.54em. Over-long words break with a trailing hyphen (a word break, not a
|
|
108
|
+
// banned spaced dash).
|
|
109
|
+
function wrapText(text: string, maxWidthPx: number, fontSizePx: number, avg = 0.54): string[] {
|
|
110
|
+
const maxChars = Math.max(6, Math.floor(maxWidthPx / (fontSizePx * avg)));
|
|
111
|
+
const out: string[] = [];
|
|
112
|
+
let line = "";
|
|
113
|
+
for (let word of String(text).trim().split(/\s+/)) {
|
|
114
|
+
while (word.length > maxChars) {
|
|
115
|
+
if (line) { out.push(line); line = ""; }
|
|
116
|
+
out.push(word.slice(0, maxChars - 1) + "-");
|
|
117
|
+
word = word.slice(maxChars - 1);
|
|
118
|
+
}
|
|
119
|
+
const test = line ? line + " " + word : word;
|
|
120
|
+
if (test.length > maxChars && line) { out.push(line); line = word; }
|
|
121
|
+
else line = test;
|
|
122
|
+
}
|
|
123
|
+
if (line) out.push(line);
|
|
124
|
+
return out.length ? out : [""];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- markdown artifacts (dash-free) ------------------------------------------
|
|
128
|
+
|
|
129
|
+
export function glossaryMarkdown(grill: Grill | null | undefined): string {
|
|
130
|
+
const terms = grill?.glossary || [];
|
|
131
|
+
return `# Domain Glossary\n\n${terms.map((g) => `- **${g.term}**: ${g.definition}`).join("\n")}\n`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const REQ_CATS = ["persona", "journey", "ux", "functional", "business-rule", "data", "integration", "non-functional", "metric"];
|
|
135
|
+
|
|
136
|
+
export function requirementsMarkdown(grill: Grill | null | undefined): string {
|
|
137
|
+
const reqs = (grill?.requirements || []).map((r) => ({
|
|
138
|
+
id: r.id || "",
|
|
139
|
+
category: r.category || "functional",
|
|
140
|
+
text: r.text ?? r.statement ?? "",
|
|
141
|
+
}));
|
|
142
|
+
let md = "# Requirements\n";
|
|
143
|
+
for (const cat of REQ_CATS) {
|
|
144
|
+
const inCat = reqs.filter((r) => r.category === cat);
|
|
145
|
+
if (!inCat.length) continue;
|
|
146
|
+
md += `\n## ${cat.replace("-", " ").replace(/\b\w/g, (c) => c.toUpperCase())}\n\n`;
|
|
147
|
+
md += inCat.map((r) => `- **${r.id}** ${r.text}`).join("\n") + "\n";
|
|
148
|
+
}
|
|
149
|
+
return md;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function swotMarkdown(swot: Swot | null | undefined): string {
|
|
153
|
+
if (!swot) return "";
|
|
154
|
+
const quads: Array<[keyof Swot, string]> = [
|
|
155
|
+
["strengths", "Strengths"], ["weaknesses", "Weaknesses"],
|
|
156
|
+
["opportunities", "Opportunities"], ["threats", "Threats"],
|
|
157
|
+
];
|
|
158
|
+
let md = "# SWOT Analysis\n";
|
|
159
|
+
for (const [k, label] of quads) {
|
|
160
|
+
const items = (swot[k] || []).filter((x) => x && x.trim());
|
|
161
|
+
md += `\n## ${label}\n\n${items.length ? items.map((i) => `- ${i}`).join("\n") : "_(none)_"}\n`;
|
|
162
|
+
}
|
|
163
|
+
return md;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function brandGuideMarkdown(guide: BrandGuide | null | undefined): string {
|
|
167
|
+
if (!guide) return "";
|
|
168
|
+
const pal = (guide.palette || []).map((p) => `- **${p.name}** \`${p.hex}\` (${p.role})`).join("\n");
|
|
169
|
+
const msg = (guide.messaging || []).map((m) => `- ${m}`).join("\n");
|
|
170
|
+
const dos = (guide.voice?.dos || []).map((d) => `- ${d}`).join("\n");
|
|
171
|
+
const donts = (guide.voice?.donts || []).map((d) => `- ${d}`).join("\n");
|
|
172
|
+
const disp = guide.typography?.display;
|
|
173
|
+
const body = guide.typography?.body;
|
|
174
|
+
return `# Brand Style Guide: ${guide.brandName || ""}
|
|
175
|
+
|
|
176
|
+
> ${guide.tagline || ""}
|
|
177
|
+
|
|
178
|
+
**Positioning.** ${guide.positioning || ""}
|
|
179
|
+
|
|
180
|
+
**Personality.** ${(guide.personality || []).join(", ")}
|
|
181
|
+
|
|
182
|
+
## Voice and Tone
|
|
183
|
+
${guide.voice?.tone || ""}
|
|
184
|
+
|
|
185
|
+
**Do**
|
|
186
|
+
${dos || "- (none)"}
|
|
187
|
+
|
|
188
|
+
**Don't**
|
|
189
|
+
${donts || "- (none)"}
|
|
190
|
+
|
|
191
|
+
## Color Palette
|
|
192
|
+
${pal || "- (none)"}
|
|
193
|
+
|
|
194
|
+
## Typography
|
|
195
|
+
- **Display:** ${disp?.family || ""} (${disp?.weights || ""}). Usage: ${disp?.usage || ""}
|
|
196
|
+
- **Body:** ${body?.family || ""} (${body?.weights || ""}). Usage: ${body?.usage || ""}
|
|
197
|
+
|
|
198
|
+
## Logo Direction
|
|
199
|
+
${guide.logoDirection || ""}
|
|
200
|
+
|
|
201
|
+
## Imagery and Iconography
|
|
202
|
+
${guide.imagery || ""}
|
|
203
|
+
|
|
204
|
+
## Messaging Examples
|
|
205
|
+
${msg || "- (none)"}
|
|
206
|
+
`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- the build-kit prompt (dash-free) ----------------------------------------
|
|
210
|
+
|
|
211
|
+
// Set-aside to-dos, grouped by question id (the kit has no section catalog, so
|
|
212
|
+
// it groups by qId rather than the web app's section title).
|
|
213
|
+
function collectTodos(feedback: Record<string, unknown>): Array<{ qId: string; summaries: string[] }> {
|
|
214
|
+
const out: Array<{ qId: string; summaries: string[] }> = [];
|
|
215
|
+
for (const [qId, v] of Object.entries(feedback || {})) {
|
|
216
|
+
const todos = (v as { todos?: Array<{ summary?: string }> } | null)?.todos;
|
|
217
|
+
if (todos?.length) out.push({ qId, summaries: todos.map((t) => t.summary || "to-do") });
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const SEO_DEPLOY_SECTION = `## Discoverability and SEO (required)
|
|
223
|
+
Build the product so both people and AI assistants can find and trust it. Implement all of the following:
|
|
224
|
+
- Structured data: add schema.org JSON-LD as one linked @graph. Always include an Organization and a Person (the founder), cross-referenced (Organization.founder and Person.worksFor) and each with a sameAs array pointing to real profiles. Add a WebSite node and the type that matches the page (WebPage, SoftwareApplication, or Product). Add FAQPage wherever there is an FAQ, HowTo for any step-by-step process, BreadcrumbList on every real sub-page, and Article or BlogPosting on real article pages. Never add a schema type that does not match visible on-page content.
|
|
225
|
+
- llms.txt: create /llms.txt at the site root with an H1 title, a one-paragraph summary in a blockquote, sections that link the key pages, and a short FAQ, so AI crawlers can extract what the site is and does.
|
|
226
|
+
- sitemap.xml listing every real page, and robots.txt that allows crawling and points to the sitemap.
|
|
227
|
+
- A canonical link on every page.
|
|
228
|
+
- Open Graph and Twitter Card meta on every page, plus a 1200x630 social image.
|
|
229
|
+
- Semantic, accessible HTML: landmark elements, headings in order, alt text on images, labelled form controls, and a visible focus style.
|
|
230
|
+
- An SEO README at SEO.md that documents every item above: where each lives and how to update it later (especially the Organization and Person sameAs links and the social image).
|
|
231
|
+
|
|
232
|
+
## Deployment (Netlify recommended)
|
|
233
|
+
Netlify is the simplest way to ship this. Include what it needs and give the founder a paste-ready deploy path:
|
|
234
|
+
- Add a netlify.toml with the build command and publish directory. For a single-page app, add a SPA redirect (/* to /index.html with status 200).
|
|
235
|
+
- Document two deploy options in SEO.md or a DEPLOY.md:
|
|
236
|
+
1. Continuous deploy: push the repo to GitHub and connect it at app.netlify.com, so every push to the main branch deploys automatically.
|
|
237
|
+
2. CLI deploy: run the build, then "npx netlify-cli deploy --prod --dir <publish-directory>".
|
|
238
|
+
- Make sure llms.txt, robots.txt, sitemap.xml, and the social image sit in the publish directory so they are served at the site root.
|
|
239
|
+
`;
|
|
240
|
+
|
|
241
|
+
export function buildKitPrompt(
|
|
242
|
+
grill: Grill | null | undefined,
|
|
243
|
+
prdMarkdown: string,
|
|
244
|
+
assumptions: Assumptions | null | undefined,
|
|
245
|
+
brand: Brand | null | undefined,
|
|
246
|
+
feedback: Record<string, unknown> = {},
|
|
247
|
+
): string {
|
|
248
|
+
const projection = assumptions ? computeProjection(assumptions) : null;
|
|
249
|
+
let bizSummary = "";
|
|
250
|
+
if (projection && assumptions) {
|
|
251
|
+
const s = projection.summary;
|
|
252
|
+
bizSummary = `Business model: ${(assumptions.parties || []).map((p) => `${p.name} (${p.role})`).join(", ")}. Monthly break-even at month ${s.breakEvenMonth ?? "beyond horizon"}; cumulative break-even at month ${s.cumBreakEvenMonth ?? "beyond horizon"}; year-1 revenue about ${fmtMoney(s.year1Revenue, assumptions.currency)}; max cash drawdown about ${fmtMoney(Math.abs(s.maxDrawdown), assumptions.currency)}.`;
|
|
253
|
+
}
|
|
254
|
+
const groups = collectTodos(feedback);
|
|
255
|
+
const todoBlock = groups.length
|
|
256
|
+
? `\n## Open validation to-dos (the founder set these aside; treat as known gaps, not blockers)\n${groups.map((g) => `- ${g.qId}: ${g.summaries.join("; ")}`).join("\n")}\n`
|
|
257
|
+
: "";
|
|
258
|
+
const hasGuide = !!brand?.guide;
|
|
259
|
+
return `# Build This Product
|
|
260
|
+
|
|
261
|
+
You are an expert full-stack engineering agent. Build version 1 of the product specified below. Work iteratively: scaffold, implement the critical user journeys first, then the remaining requirements. Ask nothing; every decision you need is in this document, and where genuinely unspecified, choose the simplest option consistent with the PRD and note it in a DECISIONS.md.
|
|
262
|
+
|
|
263
|
+
## Ground rules
|
|
264
|
+
- Honor the Domain Glossary exactly: use its terms for entities, fields, and UI labels (ubiquitous language).
|
|
265
|
+
- Every requirement has an ID (R-1, R-2, ...). Track them in a TRACEABILITY.md mapping requirement to implementation to test.
|
|
266
|
+
- Respect the "Out of Scope" list absolutely. Do not build excluded features.
|
|
267
|
+
- Mobile and desktop must both work.${hasGuide ? "\n- Apply the brand identity in the Brand section below to all UI, copy, color, and type, and to any generated assets." : ""}
|
|
268
|
+
${bizSummary ? `\n## Business context\n${bizSummary}\n` : ""}${hasGuide ? `\n## Brand identity (apply consistently)\n${brandGuideMarkdown(brand!.guide)}\n(The kit also includes Logo.svg and a rendered LookBook.html.)\n` : ""}${todoBlock}
|
|
269
|
+
${SEO_DEPLOY_SECTION}
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
${prdMarkdown || "(PRD not yet composed; generate the PRD first)"}
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
${requirementsMarkdown(grill)}
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
${glossaryMarkdown(grill)}
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---- SWOT infographic (SVG, ported faithfully) -------------------------------
|
|
285
|
+
|
|
286
|
+
export function swotInfographicSvg(swot: Swot | null | undefined, brand: Brand | null | undefined): string {
|
|
287
|
+
const t = brandTokens(brand?.guide);
|
|
288
|
+
const { style: fontStyle, disp } = svgFontBlock(t);
|
|
289
|
+
const surface = t?.surface || "#f7f8fa";
|
|
290
|
+
const ink = t?.ink || "#0f1729";
|
|
291
|
+
|
|
292
|
+
const W = 1000, spineX = W / 2, R = 34, LH = 19, FS = 13.5;
|
|
293
|
+
const headerH = 104, footerH = 42, gapRow = 26;
|
|
294
|
+
const PANEL = { L: { x: 28, w: 444 }, R: { x: 528, w: 444 } };
|
|
295
|
+
const textW = 360;
|
|
296
|
+
|
|
297
|
+
const quads = [
|
|
298
|
+
{ key: "strengths" as const, label: "STRENGTHS", color: "#059669", icon: "up", side: -1, row: 0 },
|
|
299
|
+
{ key: "weaknesses" as const, label: "WEAKNESSES", color: "#dc2626", icon: "down", side: -1, row: 1 },
|
|
300
|
+
{ key: "opportunities" as const, label: "OPPORTUNITIES", color: "#4f46e5", icon: "search", side: 1, row: 0 },
|
|
301
|
+
{ key: "threats" as const, label: "THREATS", color: "#d97706", icon: "warn", side: 1, row: 1 },
|
|
302
|
+
].map((q) => ({
|
|
303
|
+
...q,
|
|
304
|
+
items: ((swot?.[q.key] || []) as string[]).map((x) => String(x || "").trim()).filter(Boolean).slice(0, 8).map((s) => wrapText(s, textW, FS)),
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
const bulletsH = (q: typeof quads[number]) => (q.items.length ? q.items.reduce((a, lines) => a + lines.length * LH + 10, 0) : 26);
|
|
308
|
+
const quadH = (q: typeof quads[number]) => 94 + bulletsH(q) + 14;
|
|
309
|
+
const rowH = [Math.max(quadH(quads[0]!), quadH(quads[2]!)), Math.max(quadH(quads[1]!), quadH(quads[3]!))];
|
|
310
|
+
const rowY = [headerH + gapRow, 0];
|
|
311
|
+
rowY[1] = rowY[0]! + rowH[0]! + gapRow;
|
|
312
|
+
const footerTop = rowY[1]! + rowH[1]! + gapRow;
|
|
313
|
+
const H = footerTop + footerH;
|
|
314
|
+
|
|
315
|
+
const icon = (type: string, cx: number, cy: number): string => {
|
|
316
|
+
const P = (d: string, w = 5) => `<path d="${d}" fill="none" stroke="#ffffff" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
317
|
+
if (type === "up") return P(`M${cx} ${cy + 13} L${cx} ${cy - 13} M${cx - 9} ${cy - 4} L${cx} ${cy - 13} L${cx + 9} ${cy - 4}`);
|
|
318
|
+
if (type === "down") return P(`M${cx} ${cy - 13} L${cx} ${cy + 13} M${cx - 9} ${cy + 4} L${cx} ${cy + 13} L${cx + 9} ${cy + 4}`);
|
|
319
|
+
if (type === "search") return `<circle cx="${cx - 3}" cy="${cy - 3}" r="9.5" fill="none" stroke="#ffffff" stroke-width="4.5"/>` + P(`M${cx + 4} ${cy + 4} L${cx + 13} ${cy + 13}`);
|
|
320
|
+
return P(`M${cx} ${cy - 15} L${cx + 15} ${cy + 12} L${cx - 15} ${cy + 12} Z`, 4.5) +
|
|
321
|
+
`<rect x="${cx - 2.5}" y="${cy - 6}" width="5" height="11" rx="2.5" fill="#ffffff"/><circle cx="${cx}" cy="${cy + 9}" r="2.7" fill="#ffffff"/>`;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const quadrant = (q: typeof quads[number]): string => {
|
|
325
|
+
const pan = q.side < 0 ? PANEL.L : PANEL.R;
|
|
326
|
+
const top = rowY[q.row]!, rh = rowH[q.row]!;
|
|
327
|
+
const my = top + 40;
|
|
328
|
+
const mx = spineX + q.side * 70;
|
|
329
|
+
const innerEdge = mx - q.side * R;
|
|
330
|
+
const textX = pan.x + 42, dotX = pan.x + 26;
|
|
331
|
+
|
|
332
|
+
let b = `<rect x="${pan.x}" y="${top + 10}" width="${pan.w}" height="${rh - 10}" rx="18" fill="${q.color}" opacity="0.06"/>`;
|
|
333
|
+
b += `<rect x="${pan.x}" y="${top + 10}" width="${pan.w}" height="${rh - 10}" rx="18" fill="none" stroke="${q.color}" stroke-opacity="0.18"/>`;
|
|
334
|
+
b += `<line x1="${spineX}" y1="${my}" x2="${innerEdge}" y2="${my}" stroke="${q.color}" stroke-width="2.5"/>`;
|
|
335
|
+
b += `<circle cx="${spineX}" cy="${my}" r="4.5" fill="${q.color}"/>`;
|
|
336
|
+
b += `<circle cx="${mx}" cy="${my}" r="${R + 6}" fill="${q.color}" opacity="0.15"/>`;
|
|
337
|
+
b += `<circle cx="${mx}" cy="${my}" r="${R}" fill="${q.color}"/>`;
|
|
338
|
+
b += icon(q.icon, mx, my);
|
|
339
|
+
const tabW = Math.round(q.label.length * 8.6) + 30, tabH = 34, tabY = my - tabH / 2;
|
|
340
|
+
const tabX = q.side < 0 ? mx - R - 12 - tabW : mx + R + 12;
|
|
341
|
+
b += `<rect x="${tabX}" y="${tabY}" width="${tabW}" height="${tabH}" rx="8" fill="${q.color}"/>`;
|
|
342
|
+
b += `<text class="${disp}" x="${tabX + tabW / 2}" y="${tabY + 22}" text-anchor="middle" font-size="13.5" font-weight="700" letter-spacing="1.5" fill="#ffffff">${esc(q.label)}</text>`;
|
|
343
|
+
let y = top + 94;
|
|
344
|
+
if (!q.items.length) return b + `<text class="s" x="${textX}" y="${y}" font-size="13" fill="#9ca3af">(none yet)</text>`;
|
|
345
|
+
for (const lines of q.items) {
|
|
346
|
+
b += `<circle cx="${dotX}" cy="${y - 4}" r="3" fill="${q.color}"/>`;
|
|
347
|
+
b += `<text class="s" x="${textX}" y="${y}" font-size="13.5" fill="#374151">` +
|
|
348
|
+
lines.map((ln, i) => `<tspan x="${textX}"${i ? ` dy="${LH}"` : ""}>${esc(ln)}</tspan>`).join("") + `</text>`;
|
|
349
|
+
y += lines.length * LH + 10;
|
|
350
|
+
}
|
|
351
|
+
return b;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
let body = `<rect width="${W}" height="${H}" fill="${surface}"/>`;
|
|
355
|
+
body += `<line x1="${spineX}" y1="${headerH}" x2="${spineX}" y2="${footerTop}" stroke="#cfd5de" stroke-width="2"/>`;
|
|
356
|
+
body += quads.map(quadrant).join("");
|
|
357
|
+
body += `<rect x="0" y="0" width="${W}" height="${headerH}" fill="${ink}"/>`;
|
|
358
|
+
body += `<text class="m" x="40" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5">THE THOUGHT LAYER</text>`;
|
|
359
|
+
body += `<text class="m" x="${W - 40}" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5" text-anchor="end">STRATEGY</text>`;
|
|
360
|
+
body += `<text class="${disp}" x="${spineX}" y="74" font-size="46" font-weight="700" letter-spacing="6" fill="#ffffff" text-anchor="middle">SWOT ANALYSIS</text>`;
|
|
361
|
+
body += `<rect x="0" y="${footerTop}" width="${W}" height="${footerH}" fill="${ink}"/>`;
|
|
362
|
+
body += `<text class="m" x="${spineX}" y="${footerTop + 26}" font-size="11" fill="#ffffff" fill-opacity="0.55" text-anchor="middle">The Thought Layer, generated locally</text>`;
|
|
363
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${fontStyle}${body}</svg>`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- business model infographic (SVG, ported faithfully) ---------------------
|
|
367
|
+
|
|
368
|
+
export function bizModelInfographicSvg(assumptions: Assumptions | null | undefined, brand: Brand | null | undefined): string | null {
|
|
369
|
+
const projection = computeProjection(assumptions);
|
|
370
|
+
if (!projection || !assumptions) return null;
|
|
371
|
+
const s = projection.summary;
|
|
372
|
+
const cur = assumptions.currency || "USD";
|
|
373
|
+
const t = brandTokens(brand?.guide);
|
|
374
|
+
const { style: fontStyle, disp } = svgFontBlock(t);
|
|
375
|
+
const surface = t?.surface || "#f7f8fa";
|
|
376
|
+
const ink = t?.ink || "#0f1729";
|
|
377
|
+
const accent = t?.accent || "#4f46e5";
|
|
378
|
+
|
|
379
|
+
const W = 1000, spineX = W / 2, M = 28, LH = 19, FS = 13.5;
|
|
380
|
+
const headerH = 104, footerH = 42;
|
|
381
|
+
const PANEL = { L: { x: 28, w: 444 }, R: { x: 528, w: 444 } };
|
|
382
|
+
|
|
383
|
+
const metrics: Array<[string, string]> = [
|
|
384
|
+
["Year 1 revenue", fmtMoney(s.year1Revenue, cur)],
|
|
385
|
+
["Monthly break-even", s.breakEvenMonth ? `Month ${s.breakEvenMonth}` : "Beyond horizon"],
|
|
386
|
+
["Max cash drawdown", fmtMoney(s.maxDrawdown, cur)],
|
|
387
|
+
[`MRR at month ${s.horizon}`, fmtMoney(s.endingMRR, cur)],
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
const milestones = (assumptions as { milestones?: Array<{ month?: number; label?: string }> }).milestones || [];
|
|
391
|
+
const partyLines = (assumptions.parties || []).slice(0, 7).map((p) =>
|
|
392
|
+
wrapText(`${p.name} (${p.role}): ${fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur)}/unit/mo, CAC ${fmtMoney(Number(p.cacPerUnit) || 0, cur)}`, PANEL.L.w - 60, FS));
|
|
393
|
+
const msLines = [...milestones].sort((a, b) => (a.month || 0) - (b.month || 0)).slice(0, 9)
|
|
394
|
+
.map((m) => ({ month: m.month ?? 0, lines: wrapText(m.label || "", PANEL.R.w - 92, FS) }));
|
|
395
|
+
const narrLines = assumptions.narrative ? wrapText(assumptions.narrative, W - 96, 12.5) : [];
|
|
396
|
+
|
|
397
|
+
const cardsY = headerH + 26, cardH = 84, cardGap = 18;
|
|
398
|
+
const cardW = (W - M * 2 - cardGap * 3) / 4;
|
|
399
|
+
|
|
400
|
+
const panelTop = cardsY + cardH + 34;
|
|
401
|
+
const listTop = panelTop + 60;
|
|
402
|
+
let ly = listTop; partyLines.forEach((lines) => { ly += lines.length * LH + 10; });
|
|
403
|
+
let ry = listTop; msLines.forEach((m) => { ry += m.lines.length * LH + 10; });
|
|
404
|
+
const colBottom = Math.max(ly, ry, listTop + 26);
|
|
405
|
+
const panelBottom = colBottom + 6;
|
|
406
|
+
const narrTop = panelBottom + 30;
|
|
407
|
+
const narrPanelH = narrLines.length * 17 + 36;
|
|
408
|
+
const footerTop = narrLines.length ? narrTop + narrPanelH + 14 : panelBottom + 24;
|
|
409
|
+
const H = footerTop + footerH;
|
|
410
|
+
|
|
411
|
+
const tab = (label: string, x: number, y: number): string => {
|
|
412
|
+
const w = Math.round(label.length * 8.6) + 28, h = 32;
|
|
413
|
+
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${accent}"/>` +
|
|
414
|
+
`<text class="${disp}" x="${x + w / 2}" y="${y + 21}" text-anchor="middle" font-size="13" font-weight="700" letter-spacing="1.5" fill="#ffffff">${esc(label)}</text>`;
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
let body = `<rect width="${W}" height="${H}" fill="${surface}"/>`;
|
|
418
|
+
metrics.forEach((m, i) => {
|
|
419
|
+
const x = M + i * (cardW + cardGap);
|
|
420
|
+
body += `<rect x="${x}" y="${cardsY}" width="${cardW}" height="${cardH}" rx="14" fill="${accent}" opacity="0.08"/>`;
|
|
421
|
+
body += `<rect x="${x}" y="${cardsY}" width="${cardW}" height="${cardH}" rx="14" fill="none" stroke="${accent}" stroke-opacity="0.2"/>`;
|
|
422
|
+
body += `<text class="s" x="${x + 18}" y="${cardsY + 30}" font-size="11.5" fill="#6b7280">${esc(m[0])}</text>`;
|
|
423
|
+
body += `<text class="${disp}" x="${x + 18}" y="${cardsY + 62}" font-size="22" font-weight="700" fill="${accent}">${esc(m[1])}</text>`;
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
body += `<rect x="${PANEL.L.x}" y="${panelTop}" width="${PANEL.L.w}" height="${panelBottom - panelTop}" rx="18" fill="${accent}" opacity="0.05"/>`;
|
|
427
|
+
body += `<rect x="${PANEL.R.x}" y="${panelTop}" width="${PANEL.R.w}" height="${panelBottom - panelTop}" rx="18" fill="${accent}" opacity="0.05"/>`;
|
|
428
|
+
body += `<line x1="${spineX}" y1="${panelTop}" x2="${spineX}" y2="${panelBottom}" stroke="#cfd5de" stroke-width="2"/>`;
|
|
429
|
+
body += tab("PARTIES", PANEL.L.x + 22, panelTop + 16);
|
|
430
|
+
body += tab("MILESTONES", PANEL.R.x + 22, panelTop + 16);
|
|
431
|
+
|
|
432
|
+
let y = listTop;
|
|
433
|
+
if (!partyLines.length) body += `<text class="s" x="${PANEL.L.x + 24}" y="${y}" font-size="13" fill="#9ca3af">(no parties)</text>`;
|
|
434
|
+
partyLines.forEach((lines) => {
|
|
435
|
+
body += `<circle cx="${PANEL.L.x + 26}" cy="${y - 4}" r="3" fill="${accent}"/>`;
|
|
436
|
+
body += `<text class="s" x="${PANEL.L.x + 40}" y="${y}" font-size="13.5" fill="#374151">` +
|
|
437
|
+
lines.map((ln, i) => `<tspan x="${PANEL.L.x + 40}"${i ? ` dy="${LH}"` : ""}>${esc(ln)}</tspan>`).join("") + `</text>`;
|
|
438
|
+
y += lines.length * LH + 10;
|
|
439
|
+
});
|
|
440
|
+
let my = listTop;
|
|
441
|
+
if (!msLines.length) body += `<text class="s" x="${PANEL.R.x + 24}" y="${my}" font-size="13" fill="#9ca3af">(no milestones)</text>`;
|
|
442
|
+
msLines.forEach((m) => {
|
|
443
|
+
body += `<text class="m" x="${PANEL.R.x + 24}" y="${my}" font-size="11" font-weight="500" fill="${accent}">M${esc(m.month)}</text>`;
|
|
444
|
+
body += `<text class="s" x="${PANEL.R.x + 60}" y="${my}" font-size="13.5" fill="#374151">` +
|
|
445
|
+
m.lines.map((ln, i) => `<tspan x="${PANEL.R.x + 60}"${i ? ` dy="${LH}"` : ""}>${esc(ln)}</tspan>`).join("") + `</text>`;
|
|
446
|
+
my += m.lines.length * LH + 10;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (narrLines.length) {
|
|
450
|
+
body += `<rect x="${M}" y="${narrTop}" width="${W - M * 2}" height="${narrPanelH}" rx="14" fill="${accent}" opacity="0.05"/>`;
|
|
451
|
+
body += `<text class="m" x="${M + 18}" y="${narrTop + 22}" font-size="10.5" letter-spacing="1.5" fill="#9ca3af">NOTES</text>`;
|
|
452
|
+
body += `<text class="s" x="${M + 18}" y="${narrTop + 42}" font-size="12.5" fill="#6b7280">` +
|
|
453
|
+
narrLines.map((ln, i) => `<tspan x="${M + 18}"${i ? ` dy="17"` : ""}>${esc(ln)}</tspan>`).join("") + `</text>`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
body += `<rect x="0" y="0" width="${W}" height="${headerH}" fill="${ink}"/>`;
|
|
457
|
+
body += `<text class="m" x="40" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5">THE THOUGHT LAYER</text>`;
|
|
458
|
+
body += `<text class="m" x="${W - 40}" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5" text-anchor="end">FINANCIALS</text>`;
|
|
459
|
+
body += `<text class="${disp}" x="${spineX}" y="74" font-size="44" font-weight="700" letter-spacing="4" fill="#ffffff" text-anchor="middle">BUSINESS MODEL</text>`;
|
|
460
|
+
body += `<rect x="0" y="${footerTop}" width="${W}" height="${footerH}" fill="${ink}"/>`;
|
|
461
|
+
body += `<text class="m" x="${spineX}" y="${footerTop + 26}" font-size="11" fill="#ffffff" fill-opacity="0.55" text-anchor="middle">The Thought Layer, figures are your assumptions, computed locally</text>`;
|
|
462
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${fontStyle}${body}</svg>`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ---- brand look book (HTML, ported faithfully) -------------------------------
|
|
466
|
+
|
|
467
|
+
export function brandLookbookHtml(guide: BrandGuide | null | undefined, logoSvg?: string): string {
|
|
468
|
+
if (!guide) return "";
|
|
469
|
+
const role = (re: string, fb: string): string => (guide.palette || []).find((p) => p?.name && new RegExp(re, "i").test(p.name))?.hex || fb;
|
|
470
|
+
const primary = role("primary", guide.palette?.[0]?.hex || "#1a1a2e");
|
|
471
|
+
const accent = role("accent|secondary", guide.palette?.[1]?.hex || "#e94f37");
|
|
472
|
+
const ink = role("ink|text|dark|black", "#14141b");
|
|
473
|
+
const surface = role("surface|background|light|paper|off.?white|cream", "#fbfaf7");
|
|
474
|
+
const muted = role("muted|gray|grey|neutral|border", "#8a8a99");
|
|
475
|
+
const disp = guide.typography?.display?.family || "Georgia";
|
|
476
|
+
const body = guide.typography?.body?.family || "system-ui";
|
|
477
|
+
const fontsUrl = `https://fonts.googleapis.com/css2?family=${fam(disp)}:wght@400;600;700&family=${fam(body)}:wght@400;500;600&display=swap`;
|
|
478
|
+
const name = esc(guide.brandName || "Your Brand");
|
|
479
|
+
const tagline = esc(guide.tagline || "");
|
|
480
|
+
const messaging = guide.messaging || [];
|
|
481
|
+
const hero = esc(messaging[0] || guide.positioning || guide.brandName || "");
|
|
482
|
+
const lockup = logoSvg || `<span class="wordmark">${name}</span>`;
|
|
483
|
+
const initials = (guide.brandName || "B").split(/\s+/).map((w) => w[0] || "").join("").slice(0, 2).toUpperCase();
|
|
484
|
+
const swatches = (guide.palette || []).map((p) =>
|
|
485
|
+
`<div class="sw"><div class="chip" style="background:${esc(p.hex)}"></div><div class="swmeta"><strong>${esc(p.name)}</strong><code>${esc(p.hex)}</code><span>${esc(p.role)}</span></div></div>`).join("");
|
|
486
|
+
const traits = (guide.personality || []).map((tr) => `<span class="pill">${esc(tr)}</span>`).join("");
|
|
487
|
+
const dos = (guide.voice?.dos || []).map((d) => `<li>${esc(d)}</li>`).join("");
|
|
488
|
+
const donts = (guide.voice?.donts || []).map((d) => `<li>${esc(d)}</li>`).join("");
|
|
489
|
+
|
|
490
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
491
|
+
<title>${name} Brand Look Book</title><link href="${fontsUrl}" rel="stylesheet"><style>
|
|
492
|
+
:root{--p:${primary};--a:${accent};--ink:${ink};--su:${surface};--mu:${muted};--disp:'${disp}',Georgia,serif;--body:'${body}',system-ui,sans-serif}
|
|
493
|
+
*{margin:0;padding:0;box-sizing:border-box}body{background:var(--su);color:var(--ink);font-family:var(--body);line-height:1.6}
|
|
494
|
+
.wrap{max-width:960px;margin:0 auto;padding:0 28px}
|
|
495
|
+
section{padding:60px 0;border-bottom:1px solid color-mix(in srgb,var(--mu) 30%,transparent)}
|
|
496
|
+
.eyebrow{font-size:12px;letter-spacing:2px;text-transform:uppercase;color:var(--mu);font-weight:600;margin-bottom:16px}
|
|
497
|
+
h2{font-family:var(--disp);font-size:30px;font-weight:700}
|
|
498
|
+
.wordmark{font-family:var(--disp);font-size:44px;font-weight:700;color:var(--su)}
|
|
499
|
+
.cover{background:var(--p);color:var(--su);padding:84px 0}
|
|
500
|
+
.cover .lockup{display:inline-block;margin-bottom:26px}.cover .lockup svg{height:64px;width:auto}
|
|
501
|
+
.cover h1{font-family:var(--disp);font-size:clamp(32px,6vw,58px);font-weight:700;line-height:1.08;margin:12px 0}
|
|
502
|
+
.cover p{font-size:18px;opacity:.85;max-width:640px}
|
|
503
|
+
.pill{display:inline-block;background:color-mix(in srgb,var(--a) 16%,transparent);color:#fff;border:1px solid color-mix(in srgb,#fff 30%,transparent);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:600;margin:0 8px 8px 0}
|
|
504
|
+
.swatches{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px}
|
|
505
|
+
.sw{border:1px solid color-mix(in srgb,var(--mu) 28%,transparent);border-radius:12px;overflow:hidden;background:#fff}
|
|
506
|
+
.chip{height:80px}.swmeta{padding:12px 14px;font-size:13px;display:flex;flex-direction:column;gap:2px}
|
|
507
|
+
.swmeta code,.swmeta span{color:var(--mu);font-size:12px}
|
|
508
|
+
.big{font-family:var(--disp);font-size:62px;font-weight:700;line-height:1.05}
|
|
509
|
+
.row{font-family:var(--disp);font-size:24px;margin-top:6px;color:var(--a)}
|
|
510
|
+
.bodyspec{font-size:16px;max-width:640px;margin-top:16px}
|
|
511
|
+
.voice{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-top:8px}
|
|
512
|
+
.col{border:1px solid color-mix(in srgb,var(--mu) 28%,transparent);border-radius:12px;padding:18px 20px;background:#fff}
|
|
513
|
+
.col h3{font-size:13px;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
|
|
514
|
+
.col ul{list-style:none}.col li{padding:5px 0 5px 22px;position:relative;font-size:14px}
|
|
515
|
+
.col li:before{position:absolute;left:0}.do li:before{content:"+";color:var(--a);font-weight:700}.dont li:before{content:"x";color:var(--mu)}
|
|
516
|
+
.apps{display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
|
517
|
+
.mock{border:1px solid color-mix(in srgb,var(--mu) 28%,transparent);border-radius:14px;overflow:hidden;background:#fff}
|
|
518
|
+
.cap{font-size:12px;color:var(--mu);padding:8px 14px;border-top:1px solid color-mix(in srgb,var(--mu) 22%,transparent)}
|
|
519
|
+
.appbar{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;background:var(--su);border-bottom:1px solid color-mix(in srgb,var(--mu) 22%,transparent)}
|
|
520
|
+
.appbar .lk svg{height:24px}.appbar .lk .wordmark{font-size:18px;color:var(--ink)}
|
|
521
|
+
.appbar nav{font-size:12px;color:var(--mu);display:flex;gap:14px}
|
|
522
|
+
.appbody{height:118px;background:repeating-linear-gradient(0deg,transparent,transparent 22px,color-mix(in srgb,var(--mu) 9%,transparent) 23px)}
|
|
523
|
+
.card{aspect-ratio:1.75;background:var(--p);color:var(--su);padding:22px;display:flex;flex-direction:column;justify-content:space-between}
|
|
524
|
+
.card .lk svg{height:26px}.card .nm{font-family:var(--disp);font-size:22px;font-weight:700}.card .tg{font-size:12px;opacity:.8}
|
|
525
|
+
.slide{padding:26px;background:var(--su)}.slide .kicker{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--a);font-weight:600}
|
|
526
|
+
.slide h4{font-family:var(--disp);font-size:25px;font-weight:700;margin-top:6px;line-height:1.15}.slide .bar{width:46px;height:4px;background:var(--a);margin-top:14px;border-radius:2px}
|
|
527
|
+
.avatar{height:158px;display:flex;align-items:center;justify-content:center}.circle{width:84px;height:84px;border-radius:50%;background:var(--a);color:#fff;display:flex;align-items:center;justify-content:center;font-family:var(--disp);font-weight:700;font-size:30px}
|
|
528
|
+
.foot{padding:36px 0;color:var(--mu);font-size:12px}
|
|
529
|
+
@media(max-width:680px){.voice,.apps{grid-template-columns:1fr}}
|
|
530
|
+
</style></head><body>
|
|
531
|
+
<section class="cover"><div class="wrap">
|
|
532
|
+
<div class="lockup">${lockup}</div>
|
|
533
|
+
<div class="eyebrow" style="color:rgba(255,255,255,.7)">Brand Look Book</div>
|
|
534
|
+
<h1>${hero}</h1>${tagline ? `<p>${tagline}</p>` : ""}
|
|
535
|
+
<div style="margin-top:20px">${traits}</div>
|
|
536
|
+
</div></section>
|
|
537
|
+
<section><div class="wrap"><div class="eyebrow">Positioning</div><h2>${name}</h2>
|
|
538
|
+
<p style="font-size:18px;max-width:680px;margin-top:8px">${esc(guide.positioning || "")}</p></div></section>
|
|
539
|
+
<section><div class="wrap"><div class="eyebrow">Color</div><h2>Palette</h2><div class="swatches" style="margin-top:20px">${swatches}</div></div></section>
|
|
540
|
+
<section><div class="wrap"><div class="eyebrow">Type</div><h2>Typography</h2>
|
|
541
|
+
<div class="big" style="margin-top:18px">${name}</div><div class="row">${esc(disp)} for display</div>
|
|
542
|
+
<p class="bodyspec">Set in ${esc(body)} for body and UI. ${esc(messaging[1] || "The quick brown fox jumps over the lazy dog 0123456789.")}</p></div></section>
|
|
543
|
+
<section><div class="wrap"><div class="eyebrow">Voice</div><h2>How the brand sounds</h2>
|
|
544
|
+
<p style="max-width:680px;margin-top:8px">${esc(guide.voice?.tone || "")}</p>
|
|
545
|
+
<div class="voice"><div class="col do"><h3>Do</h3><ul>${dos || "<li>(none)</li>"}</ul></div><div class="col dont"><h3>Don't</h3><ul>${donts || "<li>(none)</li>"}</ul></div></div></div></section>
|
|
546
|
+
<section><div class="wrap"><div class="eyebrow">In the wild</div><h2>The identity applied</h2>
|
|
547
|
+
<div class="apps" style="margin-top:20px">
|
|
548
|
+
<div class="mock"><div class="appbar"><span class="lk">${lockup}</span><nav><span>Home</span><span>Pricing</span><span>Sign in</span></nav></div><div class="appbody"></div><div class="cap">App and website header</div></div>
|
|
549
|
+
<div class="mock"><div class="card"><span class="lk" style="filter:brightness(0) invert(1)">${lockup}</span><div><div class="nm">${name}</div><div class="tg">${tagline}</div></div></div><div class="cap">Business card</div></div>
|
|
550
|
+
<div class="mock"><div class="slide"><div class="kicker">${esc((guide.personality || [])[0] || "Brand")}</div><h4>${hero}</h4><div class="bar"></div></div><div class="cap">Slide title</div></div>
|
|
551
|
+
<div class="mock"><div class="avatar"><div class="circle">${esc(initials)}</div></div><div class="cap">Social avatar</div></div>
|
|
552
|
+
</div></div></section>
|
|
553
|
+
<div class="wrap foot">${name} brand look book, generated locally by The Thought Layer. Type: ${esc(disp)} / ${esc(body)}.</div>
|
|
554
|
+
</body></html>`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---- the orchestrator --------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
export type ArtifactKind = "markdown" | "svg" | "html" | "json" | "text";
|
|
560
|
+
|
|
561
|
+
export interface ArtifactFile {
|
|
562
|
+
path: string;
|
|
563
|
+
bytes: number;
|
|
564
|
+
kind: ArtifactKind;
|
|
565
|
+
source: string; // which state slice produced it
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export interface ArtifactManifest {
|
|
569
|
+
app: "thought-layer";
|
|
570
|
+
kind: "artifacts";
|
|
571
|
+
version: 1;
|
|
572
|
+
generatedAt: string;
|
|
573
|
+
brandName: string;
|
|
574
|
+
files: ArtifactFile[];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export interface ArtifactBuildOptions {
|
|
578
|
+
generatedAt: string;
|
|
579
|
+
domain?: string;
|
|
580
|
+
founderName?: string;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const kindOf = (path: string): ArtifactKind =>
|
|
584
|
+
path.endsWith(".md") ? "markdown" : path.endsWith(".svg") ? "svg" : path.endsWith(".html") ? "html" : path.endsWith(".json") ? "json" : "text";
|
|
585
|
+
|
|
586
|
+
// Build the full artifact set from a ProgressState. Every file is gated on the
|
|
587
|
+
// relevant state slice being present, EXCEPT the landing page and the README,
|
|
588
|
+
// which the kit can always synthesize from the spec.
|
|
589
|
+
export function buildArtifactSet(state: ProgressState, opts: ArtifactBuildOptions): { files: Record<string, string>; manifest: ArtifactManifest } {
|
|
590
|
+
const files: Record<string, string> = {};
|
|
591
|
+
const sources: Record<string, string> = {};
|
|
592
|
+
const add = (path: string, content: string, source: string): void => {
|
|
593
|
+
if (content && content.trim()) { files[path] = content; sources[path] = source; }
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const brand = (state.brand && typeof state.brand === "object" ? state.brand : null) as Brand | null;
|
|
597
|
+
const grill = (state.grill && typeof state.grill === "object" ? state.grill : null) as Grill | null;
|
|
598
|
+
const swot = (state.swot && typeof state.swot === "object" ? state.swot : null) as Swot | null;
|
|
599
|
+
const prd = obj(state.prd);
|
|
600
|
+
const bizModel = obj(state.bizModel);
|
|
601
|
+
const research = obj(state.research);
|
|
602
|
+
const assumptions = (bizModel["assumptions"] || null) as Assumptions | null;
|
|
603
|
+
const prdMarkdown = str(prd["markdown"]);
|
|
604
|
+
|
|
605
|
+
// PRD + requirements + glossary (the build spec).
|
|
606
|
+
add("PRD.md", prdMarkdown, "prd");
|
|
607
|
+
if (grill?.requirements?.length) add("Requirements.md", requirementsMarkdown(grill), "grill");
|
|
608
|
+
if (grill?.glossary?.length) add("DomainGlossary.md", glossaryMarkdown(grill), "grill");
|
|
609
|
+
|
|
610
|
+
// The paste-ready agent build prompt (always available; summarizes the rest).
|
|
611
|
+
add("BuildPrompt.md", buildKitPrompt(grill, prdMarkdown, assumptions, brand, state.feedback as Record<string, unknown>), "prd+grill+bizModel+brand");
|
|
612
|
+
|
|
613
|
+
// SWOT (markdown + infographic).
|
|
614
|
+
const swotHasItems = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
|
|
615
|
+
if (swotHasItems) {
|
|
616
|
+
add("SWOT.md", swotMarkdown(swot), "swot");
|
|
617
|
+
add("SWOT.svg", swotInfographicSvg(swot, brand), "swot");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Business model infographic (the numbers as a one-pager).
|
|
621
|
+
const bizSvg = bizModelInfographicSvg(assumptions, brand);
|
|
622
|
+
if (bizSvg) add("BusinessModel.svg", bizSvg, "bizModel");
|
|
623
|
+
|
|
624
|
+
// Market research brief.
|
|
625
|
+
if (research["brief"]) {
|
|
626
|
+
add("MarketResearch.md", `# Market Research\n\n_${str(research["description"])}_\n\n${str(research["brief"])}`, "research");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Compliance report (governance, regulatory, licensing, taxation), from the
|
|
630
|
+
// thought-layer-compliance skill. The body is the agent's researched report,
|
|
631
|
+
// which already carries its own "not legal or tax advice" disclaimer.
|
|
632
|
+
const governance = obj(state.governance);
|
|
633
|
+
if (str(governance["report"]).trim()) {
|
|
634
|
+
add("Compliance.md", str(governance["report"]), "governance");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Brand kit.
|
|
638
|
+
if (brand?.guide) {
|
|
639
|
+
add("Brand/BrandStyleGuide.md", brandGuideMarkdown(brand.guide), "brand");
|
|
640
|
+
const chosen = (brand.logos || []).find((l) => l.id === brand.chosenLogoId) || (brand.logos || [])[0];
|
|
641
|
+
if (chosen?.svg) add("Brand/Logo.svg", chosen.svg, "brand");
|
|
642
|
+
add("Brand/LookBook.html", brandLookbookHtml(brand.guide, chosen?.svg), "brand");
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Landing page (the kit can always synthesize this from the spec + brand).
|
|
646
|
+
try {
|
|
647
|
+
const spec = extractScaffoldSpec(state);
|
|
648
|
+
const site = buildStarterSite(spec, { domain: opts.domain, founderName: opts.founderName });
|
|
649
|
+
for (const [name, content] of Object.entries(site.files)) add(`LandingPage/${name}`, content, "scaffold");
|
|
650
|
+
} catch { /* spec extraction is best-effort; skip the landing page on failure */ }
|
|
651
|
+
|
|
652
|
+
const brandName = str(obj(brand?.guide)["brandName"]) || "Your Product";
|
|
653
|
+
add("README.md", readmeIndex(files, brandName), "index");
|
|
654
|
+
|
|
655
|
+
const manifest: ArtifactManifest = {
|
|
656
|
+
app: "thought-layer",
|
|
657
|
+
kind: "artifacts",
|
|
658
|
+
version: 1,
|
|
659
|
+
generatedAt: opts.generatedAt,
|
|
660
|
+
brandName,
|
|
661
|
+
files: Object.keys(files).sort().map((path) => ({
|
|
662
|
+
path,
|
|
663
|
+
bytes: Buffer.byteLength(files[path]!, "utf8"),
|
|
664
|
+
kind: kindOf(path),
|
|
665
|
+
source: sources[path] || "index",
|
|
666
|
+
})),
|
|
667
|
+
};
|
|
668
|
+
return { files, manifest };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// A dash-free index of the delivered bundle.
|
|
672
|
+
function readmeIndex(files: Record<string, string>, brandName: string): string {
|
|
673
|
+
const has = (p: string) => p in files;
|
|
674
|
+
const lines: string[] = [];
|
|
675
|
+
const row = (p: string, desc: string) => { if (has(p)) lines.push(`- **${p}**: ${desc}`); };
|
|
676
|
+
row("PRD.md", "Complete product requirements document");
|
|
677
|
+
row("Requirements.md", "Numbered, testable requirements by category");
|
|
678
|
+
row("DomainGlossary.md", "Ubiquitous language for the domain");
|
|
679
|
+
row("BuildPrompt.md", "Paste into an AI coding agent (Claude Code, Cursor) to build version 1");
|
|
680
|
+
row("BusinessModel.svg", "The numbers as a one-page infographic");
|
|
681
|
+
row("SWOT.md", "Strengths, weaknesses, opportunities, threats");
|
|
682
|
+
row("SWOT.svg", "The SWOT as a poster infographic");
|
|
683
|
+
row("MarketResearch.md", "The market research brief");
|
|
684
|
+
row("Compliance.md", "Governance, licensing, and tax requirements to review with your legal and tax advisors");
|
|
685
|
+
row("Brand/BrandStyleGuide.md", "Brand voice, palette, and typography");
|
|
686
|
+
row("Brand/Logo.svg", "The chosen logo (vector, editable)");
|
|
687
|
+
row("Brand/LookBook.html", "The identity applied; open in any browser");
|
|
688
|
+
row("LandingPage/index.html", "A deployable landing page; drag onto app.netlify.com/drop");
|
|
689
|
+
return `# ${brandName}: Thought Layer artifacts\n\nEverything The Thought Layer built for this idea, delivered to your own repo.\n\n${lines.join("\n")}\n`;
|
|
690
|
+
}
|