Skip to content

Commit 650d05a

Browse files
author
claude
committed
site: lead homepage with LLM bundle generator
The product is 'describe your business → get a self-hosted tech stack you own.' The prior hero explained that in prose and pointed at /desktop/. Visitors had to translate copy into an imagined experience before they had any reason to click through. This change replaces the hero with the generator itself — same endpoint the desktop uses (/api/recommend), L1/L2/L3 caching already live — so the first interaction on stockyard.dev IS the product doing its thing. Shape of the change: site/index.html - New hero: input + submit + 5 example chips (same five the desktop surfaces: yoga studio, tattoo shop, minecraft server, therapist, accounting firm). Result renders in-place below the input as a card with title, audience, tools with 'replaces X ($N/mo)' lines, savings/yr number, and an 'Install Stockyard' CTA linking to /desktop/. - Headline: 'Type what you do. Get a tech stack you own.' Sub-copy keeps the ownership + local pitch. Pricing footnote moves into hero-note so it's not buried. - Old 'You didn't start your business to pay six SaaS bills' copy is retired from the hero — the sections below (/how it works, /versus SaaS, /what you get, /pricing preview, /trust) are all untouched and now serve as the read-further-if-convinced path. CSS - .hero-gen / .gen-form / .gen-chips / .gen-card / .gen-tools / .gen-savings / .gen-thinking / .gen-error. All tokenized against existing --rust / --bg2 / --cream vars — no new colors. - Thinking state uses a gentle opacity pulse (gen-pulse keyframes). - Mobile: form stacks vertically under 640px, tools grid collapses to single column, h1 shrinks. JS (inline, no build step) - Submits to POST /api/recommend with {description}. - Client-side length check (3-500) matches server bounds. - 45s AbortController timeout; distinct error messaging for abort / 429 / other. - Chips call the same submit path. - Single-flight guard (inFlight) + button disabled while pending. - GA4 event hero_toolkit_generated with {layer, tool_count}. - HTML-escapes everything user-derived before injecting. What this doesn't change: - /api/recommend server behavior — cache layers, rate limits, Haiku→Sonnet fallback, all unchanged. If launch traffic exposes rate-limit settings that were tuned for a less-prominent endpoint, tune in a follow-up. - Nav, footer, meta/OG tags, GA4 ID — preserved exactly. - The 'not-hero' sections below (what/versus/whatyouget/pricing- preview/trust) — untouched. Verification: - Synced site/ → internal/site/static/ via make site-sync - go build ./internal/site/... clean (embed compiles) - go vet ./internal/site/... clean - Inline JS: braces and parens balanced, node --check clean - HTML parser: no errors - Secret scan (git diff | grep ghp_|sk-ant|sk-proj|AKIA|_pat_| sk_live_|sk_test_|password=): 0 hits - Live /api/recommend returns expected shape (verified this session with three long-tail queries: mobile dog groomer, small batch mead brewery, traveling tarot reader; all 6-8s cold, all tools resolve in desktop catalog, zero filtering)
1 parent 6ebb955 commit 650d05a

2 files changed

Lines changed: 424 additions & 16 deletions

File tree

internal/site/static/index.html

Lines changed: 212 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,42 @@
5757
.btn-outline:hover{background:var(--bg3);color:var(--cream);border-color:var(--rust)}
5858
.hero-note{font-family:var(--font-mono);font-size:.7rem;color:var(--cream-muted);letter-spacing:.5px;margin-top:.8rem}
5959

60+
/* ── Hero generator ───────────────────────────────────────────────── */
61+
.hero-gen{padding-top:3rem;padding-bottom:3rem}
62+
.hero-gen h1{margin-bottom:1rem}
63+
.hero-gen .sub{margin-bottom:1.8rem}
64+
.gen-form{display:flex;gap:.5rem;max-width:640px;margin:0 auto 1rem;flex-wrap:wrap}
65+
.gen-input{flex:1 1 260px;min-width:0;padding:.95rem 1.1rem;background:var(--bg2);border:1px solid var(--bg3);color:var(--cream);font-family:var(--font-serif);font-size:1rem;border-radius:2px;outline:none;transition:border-color .15s}
66+
.gen-input:focus{border-color:var(--rust)}
67+
.gen-input::placeholder{color:var(--cream-muted);font-style:italic}
68+
.gen-btn{padding:.95rem 1.6rem;background:var(--rust);color:var(--cream);border:none;font-family:var(--font-mono);font-size:.78rem;letter-spacing:1.5px;text-transform:uppercase;cursor:pointer;border-radius:2px;transition:background .15s;white-space:nowrap}
69+
.gen-btn:hover{background:var(--rust-light)}
70+
.gen-btn:disabled{opacity:.6;cursor:wait}
71+
.gen-chips{display:flex;flex-wrap:wrap;gap:.5rem;justify-content:center;align-items:center;margin-bottom:1.5rem;font-family:var(--font-mono);font-size:.75rem}
72+
.gen-chips-prefix{color:var(--cream-muted);letter-spacing:1px;text-transform:uppercase;font-size:.68rem}
73+
.gen-chip{background:transparent;border:1px solid var(--bg3);color:var(--cream-dim);padding:.4rem .75rem;font-family:var(--font-mono);font-size:.75rem;cursor:pointer;border-radius:2px;transition:all .15s}
74+
.gen-chip:hover{border-color:var(--rust);color:var(--rust-light)}
75+
.gen-result{max-width:780px;margin:1.5rem auto 0;text-align:left;min-height:0}
76+
.gen-result:empty{display:none}
77+
.gen-thinking{background:var(--bg2);border:1px solid var(--bg3);padding:1.5rem;text-align:center;font-family:var(--font-mono);font-size:.82rem;color:var(--cream-dim);border-radius:2px;animation:gen-pulse 1.6s ease-in-out infinite}
78+
.gen-thinking strong{color:var(--rust-light);font-weight:500}
79+
@keyframes gen-pulse{0%,100%{opacity:.7}50%{opacity:1}}
80+
.gen-card{background:var(--bg2);border:1px solid var(--bg3);padding:1.8rem;border-radius:2px}
81+
.gen-card-head{border-bottom:1px solid var(--bg3);padding-bottom:1rem;margin-bottom:1rem}
82+
.gen-card-title{font-family:var(--font-serif);font-size:1.3rem;color:var(--cream);margin-bottom:.3rem;line-height:1.3}
83+
.gen-card-audience{font-size:.85rem;color:var(--cream-dim);font-style:italic;line-height:1.5}
84+
.gen-tools{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.6rem;margin-bottom:1.2rem}
85+
.gen-tool{background:var(--bg);border:1px solid var(--bg3);padding:.75rem .9rem;border-radius:2px}
86+
.gen-tool-name{font-family:var(--font-mono);font-size:.82rem;color:var(--leather-light);margin-bottom:.2rem;letter-spacing:.5px}
87+
.gen-tool-replaces{font-size:.72rem;color:var(--cream-muted);font-family:var(--font-mono)}
88+
.gen-savings{display:flex;justify-content:space-between;align-items:center;padding-top:1rem;border-top:1px solid var(--bg3);font-family:var(--font-mono);font-size:.8rem;flex-wrap:wrap;gap:.8rem}
89+
.gen-savings-amount{color:var(--gold);font-size:1.1rem;font-weight:600}
90+
.gen-savings-label{color:var(--cream-muted);letter-spacing:1px;text-transform:uppercase;font-size:.68rem}
91+
.gen-cta{display:inline-block;padding:.75rem 1.3rem;background:var(--rust);color:var(--cream);font-family:var(--font-mono);font-size:.75rem;letter-spacing:1.5px;text-transform:uppercase;text-decoration:none;border-radius:2px;transition:background .15s}
92+
.gen-cta:hover{background:var(--rust-light);color:var(--cream)}
93+
.gen-error{background:var(--bg2);border:1px solid var(--bg3);padding:1rem 1.2rem;font-family:var(--font-mono);font-size:.78rem;color:var(--cream-dim);border-radius:2px;text-align:center}
94+
.gen-error a{color:var(--rust-light)}
95+
6096
/* ── App screenshot (mocked) ──────────────────────────────────────── */
6197
.shot-wrap{max-width:960px;margin:2rem auto 3rem;padding:0 2rem}
6298
.shot{background:var(--bg2);border:1px solid var(--bg3);box-shadow:0 12px 40px rgba(0,0,0,.35);position:relative}
@@ -151,6 +187,10 @@
151187
.hero{padding-top:2.5rem}
152188
.hero-actions{flex-direction:column;align-items:stretch;max-width:300px;margin:0 auto 1rem}
153189
.nav-links{gap:.8rem;font-size:.75rem}
190+
.gen-form{flex-direction:column;max-width:360px}
191+
.gen-input,.gen-btn{width:100%}
192+
.gen-tools{grid-template-columns:1fr}
193+
.hero-gen h1{font-size:1.7rem}
154194
}
155195
</style>
156196
</head>
@@ -174,15 +214,24 @@
174214
</nav>
175215

176216
<!-- ── Hero ──────────────────────────────────────────────────────── -->
177-
<section class="hero">
178-
<div class="hero-eyebrow">For small business owners</div>
179-
<h1>You didn't start your business to pay six SaaS bills.</h1>
180-
<p class="sub">Stockyard is one app on your computer that replaces your scheduling tool, your invoice tool, your member management, and your email blasts. Your data lives in a file you own. Works offline. No per-seat fees. Starts at $49/month for cloud backup, or $299 once if you want to keep it all local.</p>
181-
<div class="hero-actions">
182-
<a href="/desktop/" class="btn">See pricing</a>
183-
<a href="#what" class="btn btn-outline">How it works</a>
217+
<section class="hero hero-gen">
218+
<div class="hero-eyebrow">Describe your business</div>
219+
<h1>Type what you do. Get a tech stack you own.</h1>
220+
<p class="sub">Stockyard builds you a personalized toolkit from 164 self-hosted tools. Scheduling, invoicing, customer records, email — composed to fit your specific business, running on your own computer. Your data in a file you own.</p>
221+
<form class="gen-form" id="gen-form" autocomplete="off">
222+
<input class="gen-input" id="gen-input" type="text" placeholder="mobile dog groomer in Portland" maxlength="500" aria-label="Describe your business" />
223+
<button class="gen-btn" id="gen-btn" type="submit">Build toolkit</button>
224+
</form>
225+
<div class="gen-chips" id="gen-chips">
226+
<span class="gen-chips-prefix">try:</span>
227+
<button class="gen-chip" data-chip="yoga studio">yoga studio</button>
228+
<button class="gen-chip" data-chip="tattoo shop">tattoo shop</button>
229+
<button class="gen-chip" data-chip="minecraft server">minecraft server</button>
230+
<button class="gen-chip" data-chip="therapist">therapist</button>
231+
<button class="gen-chip" data-chip="accounting firm">accounting firm</button>
184232
</div>
185-
<div class="hero-note">Mac, Windows, Linux. Your data stays on your computer.</div>
233+
<div class="gen-result" id="gen-result" aria-live="polite"></div>
234+
<div class="hero-note">Starts at $49/month with cloud backup, or $299 once for local. Mac, Windows, Linux.</div>
186235
</section>
187236

188237
<!-- ── Mocked app screenshot ────────────────────────────────────── -->
@@ -464,5 +513,160 @@ <h2>If Stockyard disappears tomorrow, your software keeps working.</h2>
464513
<p class="fine">hello@stockyard.dev · Built by a solo developer in Minnesota · © 2026</p>
465514
</footer>
466515

516+
<script>
517+
(function(){
518+
var form = document.getElementById('gen-form');
519+
var input = document.getElementById('gen-input');
520+
var btn = document.getElementById('gen-btn');
521+
var result = document.getElementById('gen-result');
522+
if (!form || !input || !btn || !result) return;
523+
524+
function escapeHtml(s){
525+
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
526+
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
527+
});
528+
}
529+
530+
function articleFor(word){
531+
var first = (word || '').trim().charAt(0).toLowerCase();
532+
return 'aeiou'.indexOf(first) !== -1 ? 'an' : 'a';
533+
}
534+
535+
function renderThinking(desc){
536+
result.innerHTML =
537+
'<div class="gen-thinking">Building a toolkit for ' + articleFor(desc) + ' <strong>' +
538+
escapeHtml(desc) + '</strong>…</div>';
539+
}
540+
541+
function renderError(msg, showCategoryLink){
542+
var link = showCategoryLink
543+
? ' <a href="/for/">Browse toolkits by category →</a>'
544+
: '';
545+
result.innerHTML = '<div class="gen-error">' + escapeHtml(msg) + link + '</div>';
546+
}
547+
548+
function renderResult(data, userInput){
549+
var tools = (data && data.tools) || [];
550+
if (tools.length === 0){
551+
renderError("We couldn't build a toolkit for that. Try describing it differently, or", true);
552+
return;
553+
}
554+
var savings = data.savings_per_year || 0;
555+
var replaces = data.total_replaces_cost || 0;
556+
var title = data.title || 'Your Toolkit';
557+
var audience = data.audience || '';
558+
var cards = tools.map(function(t){
559+
var replacesLine = t.replaces
560+
? 'replaces ' + escapeHtml(t.replaces) +
561+
(t.replaces_cost ? ' ($' + t.replaces_cost + '/mo)' : '')
562+
: '';
563+
return '<div class="gen-tool">' +
564+
'<div class="gen-tool-name">' + escapeHtml(t.label || t.slug) + '</div>' +
565+
(replacesLine ? '<div class="gen-tool-replaces">' + replacesLine + '</div>' : '') +
566+
'</div>';
567+
}).join('');
568+
var savingsBlock = savings > 0
569+
? '<div class="gen-savings">' +
570+
'<div>' +
571+
'<div class="gen-savings-label">Replaces</div>' +
572+
'<div class="gen-savings-amount">$' + savings.toLocaleString() + '/yr</div>' +
573+
'</div>' +
574+
'<a class="gen-cta" href="/desktop/">Install Stockyard →</a>' +
575+
'</div>'
576+
: '<div class="gen-savings">' +
577+
'<div></div>' +
578+
'<a class="gen-cta" href="/desktop/">Install Stockyard →</a>' +
579+
'</div>';
580+
result.innerHTML =
581+
'<div class="gen-card">' +
582+
'<div class="gen-card-head">' +
583+
'<div class="gen-card-title">' + escapeHtml(title) + '</div>' +
584+
(audience ? '<div class="gen-card-audience">' + escapeHtml(audience) + '</div>' : '') +
585+
'</div>' +
586+
'<div class="gen-tools">' + cards + '</div>' +
587+
savingsBlock +
588+
'</div>';
589+
}
590+
591+
var inFlight = false;
592+
593+
function submit(desc){
594+
desc = (desc || '').trim();
595+
if (desc.length < 3){
596+
renderError('Please describe your business in a few words (at least 3 characters).', false);
597+
return;
598+
}
599+
if (desc.length > 500){
600+
renderError('That description is too long — please shorten to under 500 characters.', false);
601+
return;
602+
}
603+
if (inFlight) return;
604+
inFlight = true;
605+
btn.disabled = true;
606+
renderThinking(desc);
607+
608+
// Soft client timeout — 45s ceiling. Server has its own 60s for the
609+
// LLM call; this gives the user a clear stopping point if the
610+
// network itself hangs.
611+
var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
612+
var timeoutId = setTimeout(function(){
613+
if (controller) controller.abort();
614+
}, 45000);
615+
616+
fetch('/api/recommend', {
617+
method: 'POST',
618+
headers: {'Content-Type': 'application/json'},
619+
body: JSON.stringify({description: desc}),
620+
signal: controller ? controller.signal : undefined
621+
}).then(function(r){
622+
if (!r.ok){
623+
if (r.status === 429){
624+
throw new Error('rate_limited');
625+
}
626+
throw new Error('server_' + r.status);
627+
}
628+
return r.json();
629+
}).then(function(data){
630+
renderResult(data, desc);
631+
if (typeof gtag === 'function'){
632+
gtag('event', 'hero_toolkit_generated', {
633+
layer: data.cached ? 'cached' : 'fresh',
634+
tool_count: (data.tools || []).length
635+
});
636+
}
637+
}).catch(function(err){
638+
if (err && err.name === 'AbortError'){
639+
renderError("That took longer than expected. Please try again, or", true);
640+
} else if (err && err.message === 'rate_limited'){
641+
renderError("We're getting a lot of traffic — please try again in a minute, or", true);
642+
} else {
643+
renderError("Something went wrong building that toolkit. Try again, or", true);
644+
}
645+
}).then(function(){
646+
clearTimeout(timeoutId);
647+
inFlight = false;
648+
btn.disabled = false;
649+
});
650+
}
651+
652+
form.addEventListener('submit', function(e){
653+
e.preventDefault();
654+
submit(input.value);
655+
});
656+
657+
// Chip click → fill input and submit
658+
var chips = document.querySelectorAll('.gen-chip');
659+
for (var i = 0; i < chips.length; i++){
660+
chips[i].addEventListener('click', function(e){
661+
e.preventDefault();
662+
var chip = e.currentTarget.getAttribute('data-chip');
663+
if (!chip) return;
664+
input.value = chip;
665+
submit(chip);
666+
});
667+
}
668+
})();
669+
</script>
670+
467671
</body>
468672
</html>

0 commit comments

Comments
 (0)