whale-igniter 1.4.0 → 1.5.2

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.
@@ -1,21 +1,26 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
+ import prompts from "prompts";
3
4
  import { resolveTarget } from "../utils/paths.js";
4
5
  import { loadConfig } from "../utils/config.js";
5
6
  import { upsertComponent } from "../utils/components.js";
6
7
  import { generateWiki } from "../generators/wikiGenerator.js";
7
8
  import { ui } from "../ui/index.js";
8
9
  import { loadUiTheme, DEFAULT_UI_THEME } from "../themes/registry.js";
9
- const DEFAULT_SECTIONS = ["hero", "features", "proof", "contact"];
10
+ const DEFAULT_SECTIONS = ["header", "hero", "features", "proof", "contact", "footer"];
10
11
  const VALID_SECTIONS = new Set(DEFAULT_SECTIONS);
12
+ const DEFAULT_CONTENT_FILE = "landing.json";
11
13
  export async function createLandingCommand(options = {}) {
12
14
  const target = resolveTarget();
13
15
  const config = await loadConfig(target);
14
- const sections = parseSections(options.sections);
15
16
  const shouldRegister = options.register !== false && !options.noRegister;
16
17
  const shouldSync = options.sync !== false && !options.noSync;
18
+ const projectName = config.projectName ?? "Whale Landing";
19
+ const fallbackTitle = titleize(projectName);
20
+ const brief = options.brief ? await runLandingBrief(fallbackTitle) : undefined;
21
+ const sections = parseSections(options.sections ?? brief?.sections);
17
22
  if (sections.length === 0) {
18
- console.log(ui.fail("No valid sections selected. Use hero,features,proof,contact."));
23
+ console.log(ui.fail("No valid sections selected. Use header,hero,features,proof,contact,footer."));
19
24
  process.exitCode = 1;
20
25
  return;
21
26
  }
@@ -24,11 +29,16 @@ export async function createLandingCommand(options = {}) {
24
29
  const cssRel = path.join(outDir, "styles.css").replace(/\\/g, "/");
25
30
  const htmlAbs = path.join(target, htmlRel);
26
31
  const cssAbs = path.join(target, cssRel);
32
+ const shouldWriteContent = !!options.sampleContent || !!options.brief;
33
+ const contentRel = (options.content ?? DEFAULT_CONTENT_FILE).replace(/\\/g, "/");
34
+ const contentAbs = path.isAbsolute(contentRel) ? contentRel : path.join(target, contentRel);
27
35
  const existing = [];
28
36
  if (await fs.pathExists(htmlAbs))
29
37
  existing.push(htmlRel);
30
38
  if (await fs.pathExists(cssAbs))
31
39
  existing.push(cssRel);
40
+ if (shouldWriteContent && (await fs.pathExists(contentAbs)))
41
+ existing.push(contentRel);
32
42
  if (existing.length > 0 && !options.force) {
33
43
  console.log(ui.fail(`${existing.map((f) => ui.path(f)).join(", ")} already exists. Use --force to overwrite.`));
34
44
  process.exitCode = 1;
@@ -38,22 +48,30 @@ export async function createLandingCommand(options = {}) {
38
48
  console.log(ui.warn(`Project stack is ${ui.code(config.stack)}; generating portable HTML/CSS anyway.`));
39
49
  }
40
50
  await fs.ensureDir(path.dirname(htmlAbs));
41
- const projectName = config.projectName ?? "Whale Landing";
42
51
  // Resolve theme: CLI flag → config.theme → DEFAULT_UI_THEME
43
- const themeName = options.theme ?? config.theme ?? DEFAULT_UI_THEME;
52
+ const themeName = options.theme ?? brief?.theme ?? config.theme ?? DEFAULT_UI_THEME;
44
53
  const theme = await loadUiTheme(themeName);
45
54
  if (!theme) {
46
55
  console.log(ui.fail(`Unknown theme '${themeName}'. Run \`whale themes list\`.`));
47
56
  process.exitCode = 1;
48
57
  return;
49
58
  }
50
- await fs.writeFile(htmlAbs, renderHtml(projectName, sections), "utf8");
59
+ const content = await resolveLandingContent(target, fallbackTitle, sections, options, brief?.content);
60
+ if (!content)
61
+ return;
62
+ if (shouldWriteContent) {
63
+ await fs.ensureDir(path.dirname(contentAbs));
64
+ await fs.writeJson(contentAbs, content, { spaces: 2 });
65
+ }
66
+ await fs.writeFile(htmlAbs, renderHtml(content, sections), "utf8");
51
67
  await fs.writeFile(cssAbs, renderCss(theme, {
52
68
  includeProof: sections.includes("proof"),
53
69
  includeContact: sections.includes("contact")
54
70
  }), "utf8");
55
71
  console.log(ui.ok(`Created ${ui.path(htmlRel)}`));
56
72
  console.log(ui.ok(`Created ${ui.path(cssRel)}`));
73
+ if (shouldWriteContent)
74
+ console.log(ui.ok(`Created ${ui.path(contentRel)}`));
57
75
  console.log(` ${ui.kv("sections", sections.join(", "), { keyWidth: 8 })}`);
58
76
  console.log(` ${ui.kv("theme", theme.label, { keyWidth: 8 })}`);
59
77
  console.log(` ${ui.kv("grid", `${theme.structural.grid}px`, { keyWidth: 8 })}`);
@@ -63,7 +81,7 @@ export async function createLandingCommand(options = {}) {
63
81
  await upsertComponent(target, {
64
82
  name: sectionName(section),
65
83
  description: sectionDescription(section),
66
- category: section === "contact" ? "form" : section === "hero" ? "layout" : "surface",
84
+ category: section === "contact" ? "form" : section === "header" || section === "footer" ? "navigation" : section === "hero" ? "layout" : "surface",
67
85
  files: [htmlRel, cssRel],
68
86
  variants: section === "contact" ? ["default"] : undefined,
69
87
  states: section === "contact" ? ["focus", "disabled"] : undefined,
@@ -76,6 +94,7 @@ export async function createLandingCommand(options = {}) {
76
94
  await generateWiki(target);
77
95
  console.log(ui.muted(`${ui.glyph.check} AI context updated`));
78
96
  }
97
+ printLandingHints({ contentRel, hasContent: !!options.content || shouldWriteContent, sections });
79
98
  }
80
99
  function parseSections(raw) {
81
100
  if (!raw)
@@ -93,19 +112,23 @@ function parseSections(raw) {
93
112
  }
94
113
  function sectionName(section) {
95
114
  const map = {
115
+ header: "LandingHeader",
96
116
  hero: "HeroSection",
97
117
  features: "FeatureSection",
98
118
  proof: "SocialProofSection",
99
- contact: "ContactForm"
119
+ contact: "ContactForm",
120
+ footer: "LandingFooter"
100
121
  };
101
122
  return map[section];
102
123
  }
103
124
  function sectionDescription(section) {
104
125
  const map = {
126
+ header: "Landing header with brand navigation and primary CTA.",
105
127
  hero: "Landing hero with headline, value prop and primary CTA.",
106
128
  features: "Feature grid for the landing page offer.",
107
129
  proof: "Social proof band with customer quotes and trust signals.",
108
- contact: "Contact form with accessible labels and focus states."
130
+ contact: "Contact form with accessible labels and focus states.",
131
+ footer: "Landing footer with navigation and ownership notes."
109
132
  };
110
133
  return map[section];
111
134
  }
@@ -116,40 +139,354 @@ function titleize(input) {
116
139
  .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
117
140
  .join(" ");
118
141
  }
119
- function renderHtml(projectName, sections) {
120
- const title = titleize(projectName);
121
- const blocks = sections.map((section) => renderSection(section, title, sections)).join("\n\n");
142
+ async function resolveLandingContent(target, fallbackTitle, sections, options, briefContent) {
143
+ const defaults = defaultLandingContent(fallbackTitle, sections);
144
+ let content = defaults;
145
+ if (options.sampleContent) {
146
+ content = mergeLandingContent(content, sampleLandingContent());
147
+ }
148
+ else if (briefContent) {
149
+ content = mergeLandingContent(content, briefContent);
150
+ }
151
+ else if (options.content) {
152
+ const loaded = await loadLandingContentFile(target, options.content);
153
+ if (!loaded)
154
+ return null;
155
+ content = mergeLandingContent(content, loaded);
156
+ }
157
+ const flagContent = {};
158
+ if (options.title)
159
+ flagContent.title = options.title;
160
+ if (options.eyebrow)
161
+ flagContent.eyebrow = options.eyebrow;
162
+ if (options.tagline)
163
+ flagContent.subtitle = options.tagline;
164
+ if (options.cta || options.ctaHref) {
165
+ flagContent.cta = {
166
+ ...(options.cta ? { label: options.cta } : {}),
167
+ ...(options.ctaHref ? { href: options.ctaHref } : {})
168
+ };
169
+ }
170
+ return mergeLandingContent(content, flagContent);
171
+ }
172
+ async function runLandingBrief(fallbackTitle) {
173
+ console.log(ui.section("Landing brief"));
174
+ console.log(ui.muted("Answer a few questions. Whale will generate local HTML/CSS and an editable landing.json."));
175
+ console.log();
176
+ const onCancel = () => {
177
+ console.log();
178
+ console.log(ui.warn("Landing brief cancelled."));
179
+ process.exit(130);
180
+ };
181
+ const answers = await prompts([
182
+ {
183
+ type: "text",
184
+ name: "brand",
185
+ message: "What are you launching?",
186
+ initial: fallbackTitle
187
+ },
188
+ {
189
+ type: "text",
190
+ name: "audience",
191
+ message: "Who is it for?",
192
+ initial: "teams moving fast"
193
+ },
194
+ {
195
+ type: "text",
196
+ name: "action",
197
+ message: "What should visitors do?",
198
+ initial: "start a conversation"
199
+ },
200
+ {
201
+ type: "select",
202
+ name: "theme",
203
+ message: "Pick a visual tone:",
204
+ choices: [
205
+ { title: "Atlas — neutral, enterprise, dense", value: "atlas" },
206
+ { title: "Harbor — editorial, brand, confident", value: "harbor" },
207
+ { title: "Aurora — friendly SaaS, airy", value: "aurora" }
208
+ ]
209
+ },
210
+ {
211
+ type: "multiselect",
212
+ name: "sections",
213
+ message: "Sections to generate:",
214
+ choices: [
215
+ { title: "Header", value: "header", selected: true },
216
+ { title: "Hero", value: "hero", selected: true },
217
+ { title: "Features", value: "features", selected: true },
218
+ { title: "Proof", value: "proof", selected: true },
219
+ { title: "Contact", value: "contact", selected: true },
220
+ { title: "Footer", value: "footer", selected: true }
221
+ ],
222
+ min: 1,
223
+ hint: "space to toggle, enter to confirm"
224
+ }
225
+ ], { onCancel });
226
+ const brand = normalizeBriefText(answers.brand, fallbackTitle);
227
+ const audience = normalizeBriefText(answers.audience, "teams moving fast");
228
+ const action = normalizeBriefText(answers.action, "start a conversation");
229
+ const sections = Array.isArray(answers.sections) ? answers.sections.join(",") : undefined;
230
+ const title = `${brand} helps ${audience} ${action}.`;
231
+ return {
232
+ theme: typeof answers.theme === "string" ? answers.theme : undefined,
233
+ sections,
234
+ content: {
235
+ brand,
236
+ eyebrow: `For ${audience}`,
237
+ title,
238
+ subtitle: `A focused landing page for ${audience}, built to make the next step obvious.`,
239
+ cta: {
240
+ label: toTitleCase(action),
241
+ href: sections?.includes("contact") ? "#contact" : "#features-title"
242
+ },
243
+ features: [
244
+ {
245
+ title: "Clarify the offer",
246
+ body: "Replace this with the strongest outcome your visitor gets."
247
+ },
248
+ {
249
+ title: "Show the workflow",
250
+ body: "Explain the before and after in one concrete sentence."
251
+ },
252
+ {
253
+ title: "Make action easy",
254
+ body: "Keep the next step specific, visible, and low-friction."
255
+ }
256
+ ],
257
+ proof: {
258
+ title: `Built for ${audience}.`,
259
+ quote: `${brand} gives us a clearer way to explain the offer and move serious visitors forward.`,
260
+ byline: "First customer or internal team"
261
+ },
262
+ contact: {
263
+ title: "Start with the next step",
264
+ body: "Tell us what you are building and we will follow up with a focused path forward.",
265
+ submitLabel: toTitleCase(action)
266
+ }
267
+ }
268
+ };
269
+ }
270
+ function normalizeBriefText(value, fallback) {
271
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
272
+ }
273
+ function toTitleCase(input) {
274
+ return input
275
+ .split(/\s+/)
276
+ .filter(Boolean)
277
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
278
+ .join(" ");
279
+ }
280
+ async function loadLandingContentFile(target, contentPath) {
281
+ const abs = path.isAbsolute(contentPath) ? contentPath : path.join(target, contentPath);
282
+ try {
283
+ const raw = await fs.readJson(abs);
284
+ const validated = validateLandingContent(raw);
285
+ if (!validated.ok) {
286
+ console.log(ui.fail(`${ui.path(contentPath)} is not valid landing content: ${validated.error}`));
287
+ process.exitCode = 1;
288
+ return null;
289
+ }
290
+ return validated.value;
291
+ }
292
+ catch (err) {
293
+ const message = err instanceof Error ? err.message : String(err);
294
+ console.log(ui.fail(`Could not read landing content from ${ui.path(contentPath)}: ${message}`));
295
+ process.exitCode = 1;
296
+ return null;
297
+ }
298
+ }
299
+ function validateLandingContent(value) {
300
+ if (!isRecord(value))
301
+ return { ok: false, error: "expected a JSON object" };
302
+ const input = value;
303
+ for (const key of ["brand", "eyebrow", "title", "subtitle"]) {
304
+ if (input[key] !== undefined && typeof input[key] !== "string") {
305
+ return { ok: false, error: `${key} must be a string` };
306
+ }
307
+ }
308
+ if (input.cta !== undefined) {
309
+ if (!isRecord(input.cta))
310
+ return { ok: false, error: "cta must be an object" };
311
+ if (input.cta.label !== undefined && typeof input.cta.label !== "string")
312
+ return { ok: false, error: "cta.label must be a string" };
313
+ if (input.cta.href !== undefined && typeof input.cta.href !== "string")
314
+ return { ok: false, error: "cta.href must be a string" };
315
+ }
316
+ if (input.features !== undefined) {
317
+ if (!Array.isArray(input.features))
318
+ return { ok: false, error: "features must be an array" };
319
+ if (input.features.length < 1 || input.features.length > 6)
320
+ return { ok: false, error: "features must contain 1 to 6 items" };
321
+ for (const feature of input.features) {
322
+ if (!isRecord(feature))
323
+ return { ok: false, error: "each feature must be an object" };
324
+ if (typeof feature.title !== "string" || typeof feature.body !== "string") {
325
+ return { ok: false, error: "each feature needs string title and body" };
326
+ }
327
+ }
328
+ }
329
+ for (const key of ["proof", "contact"]) {
330
+ const section = input[key];
331
+ if (section !== undefined) {
332
+ if (!isRecord(section))
333
+ return { ok: false, error: `${key} must be an object` };
334
+ for (const [field, fieldValue] of Object.entries(section)) {
335
+ if (fieldValue !== undefined && typeof fieldValue !== "string") {
336
+ return { ok: false, error: `${key}.${field} must be a string` };
337
+ }
338
+ }
339
+ }
340
+ }
341
+ return { ok: true, value: input };
342
+ }
343
+ function isRecord(value) {
344
+ return typeof value === "object" && value !== null && !Array.isArray(value);
345
+ }
346
+ function defaultLandingContent(title, sections) {
347
+ const ctaTarget = sections.includes("contact")
348
+ ? "#contact"
349
+ : sections.includes("features")
350
+ ? "#features-title"
351
+ : "#";
352
+ return {
353
+ brand: title,
354
+ eyebrow: "Launch-ready landing",
355
+ title: `${title} turns interest into qualified conversations.`,
356
+ subtitle: "A focused page with clear benefits, trust signals and a path that is easy to scan.",
357
+ cta: {
358
+ label: "Start a conversation",
359
+ href: ctaTarget
360
+ },
361
+ features: [
362
+ {
363
+ title: "Clear offer",
364
+ body: "A direct headline and CTA keep the first screen focused."
365
+ },
366
+ {
367
+ title: "Readable proof",
368
+ body: "Compact testimonials and metrics make trust easy to evaluate."
369
+ },
370
+ {
371
+ title: "Low-friction contact",
372
+ body: "The form asks only for the details needed to continue."
373
+ }
374
+ ],
375
+ proof: {
376
+ eyebrow: "Social proof",
377
+ title: "Built for teams that need momentum.",
378
+ quote: "The page gave us a cleaner way to explain the offer and capture serious leads.",
379
+ byline: "Marina Silva, Growth Lead"
380
+ },
381
+ contact: {
382
+ eyebrow: "Contact",
383
+ title: "Tell us what you want to launch.",
384
+ body: "Share a few details and we will follow up with next steps.",
385
+ submitLabel: "Send message"
386
+ }
387
+ };
388
+ }
389
+ function sampleLandingContent() {
390
+ return {
391
+ brand: "FuelForge",
392
+ eyebrow: "Nutrition planning",
393
+ title: "Meal planning for athletes who train hard.",
394
+ subtitle: "Build training-aware menus, grocery lists, and coach-ready exports without rebuilding spreadsheets.",
395
+ cta: {
396
+ label: "Request early access",
397
+ href: "#contact"
398
+ },
399
+ features: [
400
+ {
401
+ title: "Training-aware macros",
402
+ body: "Adjust calories and macros by phase, intensity, and recovery days."
403
+ },
404
+ {
405
+ title: "Smart grocery lists",
406
+ body: "Turn a week of meals into a clean shopping plan."
407
+ },
408
+ {
409
+ title: "Coach-ready exports",
410
+ body: "Share plans with athletes, coaches, or nutritionists."
411
+ }
412
+ ],
413
+ proof: {
414
+ eyebrow: "Proof",
415
+ title: "Built for coaches moving fast.",
416
+ quote: "FuelForge cut weekly meal planning from two hours to twenty minutes.",
417
+ byline: "Maya R., endurance coach"
418
+ },
419
+ contact: {
420
+ eyebrow: "Early access",
421
+ title: "Plan your next training block",
422
+ body: "Tell us what you are building and we will help map the first week.",
423
+ submitLabel: "Request access"
424
+ }
425
+ };
426
+ }
427
+ function mergeLandingContent(base, next) {
428
+ return {
429
+ ...base,
430
+ ...definedPick(next, ["brand", "eyebrow", "title", "subtitle"]),
431
+ cta: { ...base.cta, ...definedPick(next.cta ?? {}, ["label", "href"]) },
432
+ features: next.features ?? base.features,
433
+ proof: { ...base.proof, ...definedPick(next.proof ?? {}, ["eyebrow", "title", "quote", "byline"]) },
434
+ contact: { ...base.contact, ...definedPick(next.contact ?? {}, ["eyebrow", "title", "body", "submitLabel"]) }
435
+ };
436
+ }
437
+ function definedPick(source, keys) {
438
+ const out = {};
439
+ for (const key of keys) {
440
+ if (source[key] !== undefined)
441
+ out[key] = source[key];
442
+ }
443
+ return out;
444
+ }
445
+ function renderHtml(content, sections) {
446
+ const header = sections.includes("header") ? `${indent(renderSection("header", content, sections), 4)}\n` : "";
447
+ const footer = sections.includes("footer") ? `\n${indent(renderSection("footer", content, sections), 4)}` : "";
448
+ const mainBlocks = sections
449
+ .filter((section) => section !== "header" && section !== "footer")
450
+ .map((section) => renderSection(section, content, sections))
451
+ .join("\n\n");
122
452
  return `<!doctype html>
123
453
  <html lang="en">
124
454
  <head>
125
455
  <meta charset="utf-8" />
126
456
  <meta name="viewport" content="width=device-width, initial-scale=1" />
127
- <title>${title}</title>
457
+ <title>${escapeHtml(content.brand)}</title>
128
458
  <link rel="stylesheet" href="./styles.css" />
129
459
  </head>
130
460
  <body>
461
+ ${header}
131
462
  <main>
132
- ${indent(blocks, 6)}
463
+ ${indent(mainBlocks, 6)}
133
464
  </main>
465
+ ${footer}
134
466
  </body>
135
467
  </html>
136
468
  `;
137
469
  }
138
- function renderSection(section, title, sections) {
470
+ function renderSection(section, content, sections) {
139
471
  switch (section) {
472
+ case "header":
473
+ return `<header class="site-header">
474
+ <div class="section__inner site-header__inner">
475
+ <a class="brand-link" href="#hero-title">${escapeHtml(content.brand)}</a>
476
+ <nav class="site-nav" aria-label="Primary">
477
+ ${indent(renderNavLinks(sections), 6)}
478
+ </nav>
479
+ <a class="button button--compact" href="${escapeAttr(content.cta.href)}">${escapeHtml(content.cta.label)}</a>
480
+ </div>
481
+ </header>`;
140
482
  case "hero": {
141
- const ctaTarget = sections.includes("contact")
142
- ? "#contact"
143
- : sections.includes("features")
144
- ? "#features-title"
145
- : "#";
146
483
  return `<section class="section hero" aria-labelledby="hero-title">
147
484
  <div class="section__inner hero__grid">
148
485
  <div class="hero__copy">
149
- <p class="eyebrow">Launch-ready landing</p>
150
- <h1 id="hero-title">${title} turns interest into qualified conversations.</h1>
151
- <p class="lede">A focused page with clear benefits, trust signals and a path that is easy to scan.</p>
152
- <a class="button" href="${ctaTarget}">Start a conversation</a>
486
+ <p class="eyebrow">${escapeHtml(content.eyebrow)}</p>
487
+ <h1 id="hero-title">${escapeHtml(content.title)}</h1>
488
+ <p class="lede">${escapeHtml(content.subtitle)}</p>
489
+ <a class="button" href="${escapeAttr(content.cta.href)}">${escapeHtml(content.cta.label)}</a>
153
490
  </div>
154
491
  <div class="hero__panel" aria-label="Landing page highlights">
155
492
  <span>Fast setup</span>
@@ -165,18 +502,7 @@ function renderSection(section, title, sections) {
165
502
  <p class="eyebrow">Why it works</p>
166
503
  <h2 id="features-title">Everything a simple landing needs.</h2>
167
504
  <div class="feature-grid">
168
- <article class="card">
169
- <h3>Clear offer</h3>
170
- <p>A direct headline and CTA keep the first screen focused.</p>
171
- </article>
172
- <article class="card">
173
- <h3>Readable proof</h3>
174
- <p>Compact testimonials and metrics make trust easy to evaluate.</p>
175
- </article>
176
- <article class="card">
177
- <h3>Low-friction contact</h3>
178
- <p>The form asks only for the details needed to continue.</p>
179
- </article>
505
+ ${indent(content.features.map(renderFeature).join("\n"), 6)}
180
506
  </div>
181
507
  </div>
182
508
  </section>`;
@@ -184,12 +510,12 @@ function renderSection(section, title, sections) {
184
510
  return `<section class="section proof" aria-labelledby="proof-title">
185
511
  <div class="section__inner proof__grid">
186
512
  <div>
187
- <p class="eyebrow">Social proof</p>
188
- <h2 id="proof-title">Built for teams that need momentum.</h2>
513
+ <p class="eyebrow">${escapeHtml(content.proof.eyebrow)}</p>
514
+ <h2 id="proof-title">${escapeHtml(content.proof.title)}</h2>
189
515
  </div>
190
516
  <blockquote>
191
- <p>"The page gave us a cleaner way to explain the offer and capture serious leads."</p>
192
- <cite>Marina Silva, Growth Lead</cite>
517
+ <p>"${escapeHtml(content.proof.quote)}"</p>
518
+ <cite>${escapeHtml(content.proof.byline)}</cite>
193
519
  </blockquote>
194
520
  </div>
195
521
  </section>`;
@@ -197,9 +523,9 @@ function renderSection(section, title, sections) {
197
523
  return `<section class="section contact" id="contact" aria-labelledby="contact-title">
198
524
  <div class="section__inner contact__grid">
199
525
  <div>
200
- <p class="eyebrow">Contact</p>
201
- <h2 id="contact-title">Tell us what you want to launch.</h2>
202
- <p class="lede">Share a few details and we will follow up with next steps.</p>
526
+ <p class="eyebrow">${escapeHtml(content.contact.eyebrow)}</p>
527
+ <h2 id="contact-title">${escapeHtml(content.contact.title)}</h2>
528
+ <p class="lede">${escapeHtml(content.contact.body)}</p>
203
529
  </div>
204
530
  <form class="contact-form">
205
531
  <label>
@@ -214,12 +540,64 @@ function renderSection(section, title, sections) {
214
540
  Message
215
541
  <textarea name="message" rows="4" required></textarea>
216
542
  </label>
217
- <button class="button" type="submit">Send message</button>
543
+ <button class="button" type="submit">${escapeHtml(content.contact.submitLabel)}</button>
218
544
  </form>
219
545
  </div>
220
546
  </section>`;
547
+ case "footer":
548
+ return `<footer class="site-footer">
549
+ <div class="section__inner site-footer__inner">
550
+ <strong>${escapeHtml(content.brand)}</strong>
551
+ <nav class="site-nav" aria-label="Footer">
552
+ ${indent(renderNavLinks(sections), 6)}
553
+ </nav>
554
+ <span>Built as local HTML/CSS. Edit landing.json and regenerate anytime.</span>
555
+ </div>
556
+ </footer>`;
221
557
  }
222
558
  }
559
+ function renderNavLinks(sections) {
560
+ const links = [
561
+ ["features", "#features-title", "Features"],
562
+ ["proof", "#proof-title", "Proof"],
563
+ ["contact", "#contact", "Contact"]
564
+ ];
565
+ return links
566
+ .filter(([section]) => sections.includes(section))
567
+ .map(([, href, label]) => `<a href="${href}">${label}</a>`)
568
+ .join("\n");
569
+ }
570
+ function renderFeature(feature) {
571
+ return `<article class="card">
572
+ <h3>${escapeHtml(feature.title)}</h3>
573
+ <p>${escapeHtml(feature.body)}</p>
574
+ </article>`;
575
+ }
576
+ function escapeHtml(value) {
577
+ return value
578
+ .replace(/&/g, "&amp;")
579
+ .replace(/</g, "&lt;")
580
+ .replace(/>/g, "&gt;")
581
+ .replace(/"/g, "&quot;");
582
+ }
583
+ function escapeAttr(value) {
584
+ return escapeHtml(value).replace(/'/g, "&#39;");
585
+ }
586
+ function printLandingHints(args) {
587
+ console.log();
588
+ console.log(ui.section("Next edits"));
589
+ const contentHint = args.hasContent
590
+ ? `edit ${args.contentRel}, then rerun with --content ${args.contentRel} --force`
591
+ : `create landing.json with --sample-content or --brief`;
592
+ const lines = [
593
+ `${ui.kv("product", "tighten the headline and CTA around one visitor action", { keyWidth: 8 })}`,
594
+ `${ui.kv("copy", contentHint, { keyWidth: 8 })}`,
595
+ `${ui.kv("design", "try --theme atlas | harbor | aurora", { keyWidth: 8 })}`,
596
+ `${ui.kv("scope", `trim sections with --sections ${args.sections.join(",")}`, { keyWidth: 8 })}`,
597
+ `${ui.kv("dev", "wire the form action in src/index.html", { keyWidth: 8 })}`
598
+ ];
599
+ console.log(ui.indent(lines.join("\n")));
600
+ }
223
601
  function renderCss(theme, opts) {
224
602
  const { includeProof, includeContact } = opts;
225
603
  const { identity, typography, structural, interaction } = theme;
@@ -259,6 +637,13 @@ function renderCss(theme, opts) {
259
637
 
260
638
  @media (prefers-reduced-motion: reduce) {
261
639
  * { transition: none !important; }
640
+
641
+ .button:hover,
642
+ .card:hover,
643
+ .hero__panel:hover,
644
+ blockquote:hover {
645
+ transform: none !important;
646
+ }
262
647
  }
263
648
 
264
649
  * {
@@ -270,12 +655,66 @@ body {
270
655
  font-family: var(--font-body);
271
656
  color: var(--color-text);
272
657
  background: var(--color-bg);
658
+ overflow-x: hidden;
273
659
  }
274
660
 
275
661
  a {
276
662
  color: inherit;
277
663
  }
278
664
 
665
+ .site-header,
666
+ .site-footer {
667
+ padding: calc(var(--space-grid) * 2) calc(var(--space-grid) * 3);
668
+ }
669
+
670
+ .site-header {
671
+ position: sticky;
672
+ top: 0;
673
+ z-index: 10;
674
+ border-bottom: var(--border-width) solid var(--color-border);
675
+ background: color-mix(in srgb, var(--color-bg) 92%, transparent);
676
+ backdrop-filter: blur(12px);
677
+ }
678
+
679
+ .site-header__inner,
680
+ .site-footer__inner {
681
+ display: flex;
682
+ gap: calc(var(--space-grid) * 2);
683
+ align-items: center;
684
+ justify-content: space-between;
685
+ }
686
+
687
+ .brand-link {
688
+ color: var(--color-text);
689
+ font-weight: 800;
690
+ text-decoration: none;
691
+ overflow-wrap: anywhere;
692
+ }
693
+
694
+ .site-nav {
695
+ display: flex;
696
+ flex-wrap: wrap;
697
+ gap: var(--space-grid) calc(var(--space-grid) * 2);
698
+ align-items: center;
699
+ justify-content: center;
700
+ }
701
+
702
+ .site-nav a {
703
+ color: var(--color-muted);
704
+ font-weight: 700;
705
+ text-decoration: none;
706
+ transition: color var(--motion-duration) var(--motion-ease);
707
+ }
708
+
709
+ .site-nav a:hover {
710
+ color: var(--color-accent-strong);
711
+ }
712
+
713
+ .site-footer {
714
+ border-top: var(--border-width) solid var(--color-border);
715
+ color: var(--color-muted);
716
+ }
717
+
279
718
  .section {
280
719
  padding: calc(var(--space-grid) * 8) calc(var(--space-grid) * 3);
281
720
  }
@@ -284,6 +723,7 @@ a {
284
723
  width: 100%;
285
724
  max-width: calc(var(--space-grid) * 140);
286
725
  margin: 0 auto;
726
+ min-width: 0;
287
727
  }
288
728
 
289
729
  .hero {
@@ -301,12 +741,21 @@ a {
301
741
  align-items: center;
302
742
  }
303
743
 
744
+ .hero__copy,
745
+ .hero__panel,
746
+ .contact-form,
747
+ .card,
748
+ blockquote {
749
+ min-width: 0;
750
+ }
751
+
304
752
  .eyebrow {
305
753
  margin: 0 0 calc(var(--space-grid) * 2);
306
754
  color: var(--color-accent-strong);
307
755
  font-size: 0.875rem;
308
756
  font-weight: 700;
309
757
  text-transform: uppercase;
758
+ overflow-wrap: anywhere;
310
759
  }
311
760
 
312
761
  h1,
@@ -320,21 +769,24 @@ h1 {
320
769
  max-width: calc(var(--space-grid) * 88);
321
770
  margin-bottom: calc(var(--space-grid) * 3);
322
771
  font-family: var(--font-display);
323
- font-size: clamp(2.5rem, 6vw, 4.5rem);
324
- line-height: 1;
772
+ font-size: clamp(2.25rem, 10vw, 4.5rem);
773
+ line-height: 1.02;
774
+ overflow-wrap: anywhere;
325
775
  }
326
776
 
327
777
  h2 {
328
778
  max-width: calc(var(--space-grid) * 80);
329
779
  margin-bottom: calc(var(--space-grid) * 4);
330
780
  font-family: var(--font-display);
331
- font-size: clamp(2rem, 4vw, 3rem);
781
+ font-size: clamp(1.75rem, 7vw, 3rem);
332
782
  line-height: 1.1;
783
+ overflow-wrap: anywhere;
333
784
  }
334
785
 
335
786
  h3 {
336
787
  margin-bottom: var(--space-grid);
337
788
  font-size: 1.125rem;
789
+ overflow-wrap: anywhere;
338
790
  }
339
791
 
340
792
  .lede {
@@ -343,11 +795,13 @@ h3 {
343
795
  color: var(--color-muted);
344
796
  font-size: 1.125rem;
345
797
  line-height: 1.6;
798
+ overflow-wrap: anywhere;
346
799
  }
347
800
 
348
801
  .button {
349
802
  display: inline-flex;
350
803
  min-height: calc(var(--space-grid) * 6);
804
+ max-width: 100%;
351
805
  align-items: center;
352
806
  justify-content: center;
353
807
  padding: var(--space-grid) calc(var(--space-grid) * 3);
@@ -357,13 +811,25 @@ h3 {
357
811
  color: var(--color-on-accent);
358
812
  font: inherit;
359
813
  font-weight: 700;
814
+ text-align: center;
360
815
  text-decoration: none;
361
816
  cursor: pointer;
362
- transition: background var(--motion-duration) var(--motion-ease);
817
+ transition:
818
+ background var(--motion-duration) var(--motion-ease),
819
+ box-shadow var(--motion-duration) var(--motion-ease),
820
+ transform var(--motion-duration) var(--motion-ease);
821
+ overflow-wrap: anywhere;
822
+ }
823
+
824
+ .button--compact {
825
+ min-height: calc(var(--space-grid) * 5);
826
+ padding: calc(var(--space-grid) * 0.75) calc(var(--space-grid) * 2);
363
827
  }
364
828
 
365
829
  .button:hover {
366
830
  background: var(--color-accent-strong);
831
+ transform: translateY(-1px);
832
+ box-shadow: 0 8px 18px color-mix(in srgb, var(--color-accent) 20%, transparent);
367
833
  }
368
834
 
369
835
  .button:focus-visible,
@@ -386,27 +852,50 @@ blockquote,
386
852
  display: grid;
387
853
  gap: calc(var(--space-grid) * 2);
388
854
  padding: calc(var(--space-grid) * 4);
855
+ overflow-wrap: anywhere;
856
+ transition:
857
+ border-color var(--motion-duration) var(--motion-ease),
858
+ transform var(--motion-duration) var(--motion-ease),
859
+ box-shadow var(--motion-duration) var(--motion-ease);
860
+ }
861
+
862
+ .hero__panel:hover,
863
+ .card:hover,
864
+ blockquote:hover {
865
+ border-color: var(--color-accent);
866
+ transform: translateY(-2px);
389
867
  }
390
868
 
391
869
  .hero__panel strong {
392
- font-size: 2rem;
870
+ font-size: clamp(1.75rem, 8vw, 2rem);
871
+ line-height: 1.1;
393
872
  }
394
873
 
395
874
  .feature-grid {
396
875
  display: grid;
397
- grid-template-columns: repeat(3, minmax(0, 1fr));
876
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, calc(var(--space-grid) * 30)), 1fr));
398
877
  gap: calc(var(--space-grid) * 3);
399
878
  }
400
879
 
401
880
  .card {
402
881
  padding: calc(var(--space-grid) * 3);
882
+ overflow-wrap: anywhere;
883
+ transition:
884
+ border-color var(--motion-duration) var(--motion-ease),
885
+ transform var(--motion-duration) var(--motion-ease),
886
+ box-shadow var(--motion-duration) var(--motion-ease);
403
887
  }
404
888
  ${includeProof ? proofCss() : ""}
405
889
  ${includeContact ? contactCss() : ""}
406
- @media (max-width: calc(var(--space-grid) * 96)) {
890
+ @media (max-width: ${g * 96}px) {
407
891
  .hero__grid,
408
892
  .contact__grid,
409
- .proof__grid,
893
+ .proof__grid {
894
+ grid-template-columns: 1fr;
895
+ gap: calc(var(--space-grid) * 4);
896
+ align-items: stretch;
897
+ }
898
+
410
899
  .feature-grid {
411
900
  grid-template-columns: 1fr;
412
901
  }
@@ -414,6 +903,45 @@ ${includeContact ? contactCss() : ""}
414
903
  .section {
415
904
  padding: calc(var(--space-grid) * 6) calc(var(--space-grid) * 2);
416
905
  }
906
+
907
+ .hero {
908
+ min-height: auto;
909
+ }
910
+
911
+ .button {
912
+ width: 100%;
913
+ }
914
+
915
+ .contact-form {
916
+ width: 100%;
917
+ }
918
+
919
+ .site-header {
920
+ position: static;
921
+ }
922
+
923
+ .site-header__inner,
924
+ .site-footer__inner {
925
+ align-items: stretch;
926
+ flex-direction: column;
927
+ }
928
+
929
+ .site-nav {
930
+ justify-content: flex-start;
931
+ }
932
+ }
933
+
934
+ @media (max-width: ${g * 56}px) {
935
+ .section {
936
+ padding: calc(var(--space-grid) * 5) calc(var(--space-grid) * 1.5);
937
+ }
938
+
939
+ .hero__panel,
940
+ .card,
941
+ blockquote,
942
+ .contact-form {
943
+ padding: calc(var(--space-grid) * 2);
944
+ }
417
945
  }
418
946
  `;
419
947
  }
@@ -457,6 +985,7 @@ label {
457
985
  input,
458
986
  textarea {
459
987
  width: 100%;
988
+ min-width: 0;
460
989
  padding: var(--space-grid) calc(var(--space-grid) * 2);
461
990
  border: var(--border-width) solid var(--color-border);
462
991
  border-radius: var(--radius-control);