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.
- package/CHANGELOG.md +35 -0
- package/README.md +68 -15
- package/dist/commands/adopt.js +7 -5
- package/dist/commands/atlas.js +196 -0
- package/dist/commands/changes.js +53 -39
- package/dist/commands/createComponent.js +3 -0
- package/dist/commands/createLanding.js +581 -52
- package/dist/commands/decision.js +1 -1
- package/dist/commands/forge.js +159 -0
- package/dist/commands/init.js +3 -2
- package/dist/commands/insights.js +0 -2
- package/dist/commands/refine.js +1 -8
- package/dist/commands/scribe.js +189 -0
- package/dist/commands/selene.js +0 -3
- package/dist/commands/sync.js +2 -2
- package/dist/commands/watch.js +1 -1
- package/dist/index.js +52 -2
- package/dist/mcp/server.js +3 -9
- package/dist/selene/promptBuilder.js +2 -2
- package/dist/templates/claude.js +4 -4
- package/dist/templates/conventions.js +4 -2
- package/dist/templates/decisions.js +17 -16
- package/dist/ui/splash.js +12 -7
- package/dist/utils/decisions.js +93 -13
- package/dist/utils/refinements.js +86 -13
- package/dist/version.js +1 -1
- package/package.json +4 -2
|
@@ -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
|
|
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
|
|
120
|
-
const
|
|
121
|
-
|
|
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>${
|
|
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(
|
|
463
|
+
${indent(mainBlocks, 6)}
|
|
133
464
|
</main>
|
|
465
|
+
${footer}
|
|
134
466
|
</body>
|
|
135
467
|
</html>
|
|
136
468
|
`;
|
|
137
469
|
}
|
|
138
|
-
function renderSection(section,
|
|
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"
|
|
150
|
-
<h1 id="hero-title">${title}
|
|
151
|
-
<p class="lede"
|
|
152
|
-
<a class="button" href="${
|
|
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
|
-
|
|
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"
|
|
188
|
-
<h2 id="proof-title"
|
|
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>"
|
|
192
|
-
<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"
|
|
201
|
-
<h2 id="contact-title"
|
|
202
|
-
<p class="lede"
|
|
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"
|
|
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, "&")
|
|
579
|
+
.replace(/</g, "<")
|
|
580
|
+
.replace(/>/g, ">")
|
|
581
|
+
.replace(/"/g, """);
|
|
582
|
+
}
|
|
583
|
+
function escapeAttr(value) {
|
|
584
|
+
return escapeHtml(value).replace(/'/g, "'");
|
|
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.
|
|
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(
|
|
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:
|
|
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(
|
|
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:
|
|
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);
|