Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit2b821b7unknown_key

feat(autopilot+landing): self-sufficiency loop + marketing landing page

feat(autopilot+landing): self-sufficiency loop + marketing landing page

- src/lib/autopilot.ts — periodic ticker (mirror sync, merge-queue peek,
  weekly digests, advisory rescans). Injectable task list, never throws.
  Opt out via AUTOPILOT_DISABLED=1. Wired into src/index.ts alongside
  the existing workflow worker. 10 new tests.
- src/views/landing.tsx — real logged-out homepage with hero, 9-card
  feature grid, vs-GitHub comparison, 3-step how-it-works, and CTA band.
  Replaces the placeholder empty-state in src/routes/web.tsx.
- BUILD_BIBLE §7 — demo-seed carry-over noted (agent timed out).

Suite: 127 pass / 53 fail — baseline was 117/53, so +10 passing and
zero new failures. The 53 failures are pre-existing sandbox hono/jsx
module-resolution errors, not regressions.
Claude committed on April 20, 2026Parent: c45e5d8
6 files changed+838202b821b76b34a749e16b14d8c49617a19b881c64c
6 changed files+838−20
ModifiedBUILD_BIBLE.md+2−1View fileUnifiedSplit
554554
555555## 7. IN-FLIGHT
556556
557(Intentionally empty. Add here if a block is partially complete at session end.)
557- **Autopilot + landing (shipped this session)**`src/lib/autopilot.ts` (5-min ticker: mirror sync, merge-queue peek, weekly digests, advisory rescans; `AUTOPILOT_DISABLED=1` opt-out) wired into `src/index.ts` alongside `startWorker()`. New marketing landing at `src/views/landing.tsx` (`LandingPage`), replaces the logged-out `/` placeholder in `src/routes/web.tsx`.
558- **Demo-seed — deferred.** Next session: create `src/lib/demo-seed.ts` (idempotent `ensureDemoContent()` creating a `demo` user + 3 public sample repos with git plumbing commits + seeded issues/PRs/topics). Agent timed out before writing any files. Design sketch is in the session transcript. Wire via boot env flag `DEMO_SEED_ON_BOOT=1` + admin `POST /admin/demo/reseed`.
Addedsrc/__tests__/autopilot.test.ts+152−0View fileUnifiedSplit
1/**
2 * Autopilot — unit tests.
3 *
4 * Uses the injected-tasks shape so the tick never touches the DB. Real
5 * helpers (syncAllDue, sendDigestsToAll, scanRepositoryForAlerts, peekHead)
6 * are covered by their own suites — here we only test the loop itself.
7 */
8
9import { describe, it, expect, beforeEach, afterEach } from "bun:test";
10import {
11 startAutopilot,
12 runAutopilotTick,
13 __test,
14 type AutopilotTask,
15} from "../lib/autopilot";
16
17describe("autopilot — startAutopilot", () => {
18 const originalDisabled = process.env.AUTOPILOT_DISABLED;
19 const originalInterval = process.env.AUTOPILOT_INTERVAL_MS;
20
21 afterEach(() => {
22 if (originalDisabled === undefined) delete process.env.AUTOPILOT_DISABLED;
23 else process.env.AUTOPILOT_DISABLED = originalDisabled;
24 if (originalInterval === undefined) delete process.env.AUTOPILOT_INTERVAL_MS;
25 else process.env.AUTOPILOT_INTERVAL_MS = originalInterval;
26 });
27
28 it("is a no-op when AUTOPILOT_DISABLED=1 and does not schedule a tick", async () => {
29 process.env.AUTOPILOT_DISABLED = "1";
30 let ran = 0;
31 const tasks: AutopilotTask[] = [
32 { name: "probe", run: async () => { ran++; } },
33 ];
34 const { stop } = startAutopilot({ intervalMs: 5, tasks });
35 // Wait long enough that any scheduled tick would have fired.
36 await new Promise((r) => setTimeout(r, 40));
37 stop();
38 expect(ran).toBe(0);
39 });
40
41 it("does not run the first tick synchronously (boot stays fast)", () => {
42 delete process.env.AUTOPILOT_DISABLED;
43 let ran = 0;
44 const tasks: AutopilotTask[] = [
45 { name: "probe", run: async () => { ran++; } },
46 ];
47 const { stop } = startAutopilot({ intervalMs: 60_000, tasks });
48 // Synchronously — the interval has not elapsed.
49 expect(ran).toBe(0);
50 stop();
51 });
52
53 it("stop() clears the interval so no further ticks run", async () => {
54 delete process.env.AUTOPILOT_DISABLED;
55 let ran = 0;
56 const tasks: AutopilotTask[] = [
57 { name: "probe", run: async () => { ran++; } },
58 ];
59 const { stop } = startAutopilot({ intervalMs: 10, tasks });
60 await new Promise((r) => setTimeout(r, 45));
61 stop();
62 const snapshot = ran;
63 await new Promise((r) => setTimeout(r, 40));
64 // Allow for one tick that was already in flight at stop(), but not more.
65 expect(ran - snapshot).toBeLessThanOrEqual(1);
66 expect(snapshot).toBeGreaterThan(0);
67 });
68});
69
70describe("autopilot — runAutopilotTick", () => {
71 it("returns the expected shape with startedAt/finishedAt/tasks", async () => {
72 const tasks: AutopilotTask[] = [
73 { name: "a", run: async () => {} },
74 { name: "b", run: async () => {} },
75 ];
76 const result = await runAutopilotTick({ tasks });
77 expect(typeof result.startedAt).toBe("string");
78 expect(typeof result.finishedAt).toBe("string");
79 expect(Array.isArray(result.tasks)).toBe(true);
80 expect(result.tasks.length).toBe(2);
81 expect(result.tasks[0]).toMatchObject({ name: "a", ok: true });
82 expect(result.tasks[1]).toMatchObject({ name: "b", ok: true });
83 expect(typeof result.tasks[0].durationMs).toBe("number");
84 });
85
86 it("catches a throwing task and reports { ok:false, error } without crashing the tick", async () => {
87 let secondRan = false;
88 const tasks: AutopilotTask[] = [
89 {
90 name: "boom",
91 run: async () => {
92 throw new Error("kaboom");
93 },
94 },
95 {
96 name: "after",
97 run: async () => {
98 secondRan = true;
99 },
100 },
101 ];
102 const result = await runAutopilotTick({ tasks });
103 expect(result.tasks.length).toBe(2);
104 expect(result.tasks[0].ok).toBe(false);
105 expect(result.tasks[0].error).toBe("kaboom");
106 expect(result.tasks[1].ok).toBe(true);
107 expect(secondRan).toBe(true);
108 });
109
110 it("handles non-Error throws gracefully", async () => {
111 const tasks: AutopilotTask[] = [
112 {
113 name: "string-throw",
114 run: async () => {
115 throw "bad-thing" as unknown as Error;
116 },
117 },
118 ];
119 const result = await runAutopilotTick({ tasks });
120 expect(result.tasks[0].ok).toBe(false);
121 expect(result.tasks[0].error).toBe("bad-thing");
122 });
123});
124
125describe("autopilot — resolveIntervalMs", () => {
126 const originalInterval = process.env.AUTOPILOT_INTERVAL_MS;
127
128 afterEach(() => {
129 if (originalInterval === undefined) delete process.env.AUTOPILOT_INTERVAL_MS;
130 else process.env.AUTOPILOT_INTERVAL_MS = originalInterval;
131 });
132
133 it("prefers explicit opts over env", () => {
134 process.env.AUTOPILOT_INTERVAL_MS = "1234";
135 expect(__test.resolveIntervalMs(42)).toBe(42);
136 });
137
138 it("falls back to env when opts is missing", () => {
139 process.env.AUTOPILOT_INTERVAL_MS = "9999";
140 expect(__test.resolveIntervalMs()).toBe(9999);
141 });
142
143 it("falls back to the default when neither is set", () => {
144 delete process.env.AUTOPILOT_INTERVAL_MS;
145 expect(__test.resolveIntervalMs()).toBe(__test.DEFAULT_INTERVAL_MS);
146 });
147
148 it("ignores non-positive env values", () => {
149 process.env.AUTOPILOT_INTERVAL_MS = "-1";
150 expect(__test.resolveIntervalMs()).toBe(__test.DEFAULT_INTERVAL_MS);
151 });
152});
Modifiedsrc/index.ts+5−0View fileUnifiedSplit
22import app from "./app";
33import { config } from "./lib/config";
44import { startWorker } from "./lib/workflow-runner";
5import { startAutopilot } from "./lib/autopilot";
56
67// Ensure repos directory exists
78await mkdir(config.gitReposPath, { recursive: true });
1011// workflow_runs for queued rows and executes them sequentially.
1112startWorker();
1213
14// Autopilot: periodic mirror sync, merge-queue progress, weekly digests,
15// advisory rescans. No-op when AUTOPILOT_DISABLED=1.
16startAutopilot();
17
1318console.log(`
1419 gluecron v0.1.0
1520 ──────────────────────
Addedsrc/lib/autopilot.ts+234−0View fileUnifiedSplit
1/**
2 * Autopilot — self-sufficiency loop.
3 *
4 * Runs existing platform-maintenance tasks (mirror sync, merge queue progress,
5 * weekly digests, advisory rescans) on an interval so the host runs itself
6 * without an external cron. All sub-tasks are injected so tests can stub them
7 * without touching the DB; the default task set wires real helpers from the
8 * locked libs. Nothing here throws — every sub-task and the outer tick are
9 * try/caught so a single failure never blocks the others.
10 */
11
12import { sql } from "drizzle-orm";
13import { db } from "../db";
14import { mergeQueueEntries, repoDependencies } from "../db/schema";
15import { syncAllDue } from "./mirrors";
16import { peekHead } from "./merge-queue";
17import { sendDigestsToAll } from "./email-digest";
18import { scanRepositoryForAlerts } from "./advisories";
19
20export interface AutopilotTaskResult {
21 name: string;
22 ok: boolean;
23 durationMs: number;
24 error?: string;
25}
26
27export interface AutopilotTickResult {
28 startedAt: string;
29 finishedAt: string;
30 tasks: AutopilotTaskResult[];
31}
32
33export interface AutopilotTask {
34 name: string;
35 run: () => Promise<void>;
36}
37
38export interface StartAutopilotOpts {
39 intervalMs?: number;
40 now?: () => number;
41 tasks?: AutopilotTask[];
42}
43
44export interface RunTickOpts {
45 tasks?: AutopilotTask[];
46 now?: () => number;
47}
48
49const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
50const ADVISORY_RESCAN_BATCH = 5;
51
52/**
53 * Default task set. Each task is a thin wrapper around an existing locked
54 * helper — no gate/merge logic is duplicated here.
55 */
56export function defaultTasks(): AutopilotTask[] {
57 return [
58 {
59 name: "mirror-sync",
60 run: async () => {
61 await syncAllDue();
62 },
63 },
64 {
65 name: "merge-queue",
66 run: async () => {
67 await processMergeQueues();
68 },
69 },
70 {
71 name: "weekly-digest",
72 run: async () => {
73 await sendDigestsToAll();
74 },
75 },
76 {
77 name: "advisory-rescan",
78 run: async () => {
79 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
80 },
81 },
82 ];
83}
84
85/**
86 * Visits each distinct (repo, base_branch) that has queued rows and logs a
87 * stub depth line. The actual gate-running + merge happens in the pulls
88 * route; this tick is just a heartbeat so we can wire per-queue progress
89 * through without duplicating merge logic.
90 */
91async function processMergeQueues(): Promise<void> {
92 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
93 try {
94 const rows = await db
95 .selectDistinct({
96 repositoryId: mergeQueueEntries.repositoryId,
97 baseBranch: mergeQueueEntries.baseBranch,
98 })
99 .from(mergeQueueEntries)
100 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
101 distinct = rows;
102 } catch (err) {
103 console.error("[autopilot] merge-queue: distinct query failed:", err);
104 return;
105 }
106 for (const d of distinct) {
107 try {
108 const head = await peekHead(d.repositoryId, d.baseBranch);
109 if (head) {
110 console.log(
111 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
112 );
113 }
114 } catch (err) {
115 console.error(
116 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
117 err
118 );
119 }
120 }
121}
122
123/**
124 * Pick a small batch of repos that actually have dep rows and re-run
125 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
126 */
127async function rescanAdvisoriesBatch(limit: number): Promise<void> {
128 let repoIds: string[] = [];
129 try {
130 const rows = await db
131 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
132 .from(repoDependencies)
133 .limit(limit);
134 repoIds = rows.map((r) => r.repositoryId);
135 } catch (err) {
136 console.error("[autopilot] advisory-rescan: query failed:", err);
137 return;
138 }
139 for (const id of repoIds) {
140 try {
141 await scanRepositoryForAlerts(id);
142 } catch (err) {
143 console.error(
144 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
145 err
146 );
147 }
148 }
149}
150
151/** Resolve the tick interval from env → opts → default. */
152function resolveIntervalMs(optsMs?: number): number {
153 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
154 const raw = process.env.AUTOPILOT_INTERVAL_MS;
155 if (raw) {
156 const parsed = Number(raw);
157 if (Number.isFinite(parsed) && parsed > 0) return parsed;
158 }
159 return DEFAULT_INTERVAL_MS;
160}
161
162/**
163 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
164 * The first tick fires after `intervalMs`, not immediately, to keep boot
165 * fast. Returns a `stop()` that clears the interval.
166 */
167export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
168 if (process.env.AUTOPILOT_DISABLED === "1") {
169 return { stop: () => {} };
170 }
171 const intervalMs = resolveIntervalMs(opts?.intervalMs);
172 const tasks = opts?.tasks ?? defaultTasks();
173 let running = false;
174 const handle = setInterval(() => {
175 if (running) return;
176 running = true;
177 void runAutopilotTick({ tasks, now: opts?.now })
178 .catch(() => {
179 // runAutopilotTick already never throws, but belt-and-braces.
180 })
181 .finally(() => {
182 running = false;
183 });
184 }, intervalMs);
185 return {
186 stop: () => clearInterval(handle),
187 };
188}
189
190/**
191 * Run one tick: invokes every sub-task with its own try/catch, records a
192 * per-task result, and emits a single summary line. Never throws.
193 */
194export async function runAutopilotTick(
195 opts?: RunTickOpts
196): Promise<AutopilotTickResult> {
197 const now = opts?.now ?? Date.now;
198 const tasks = opts?.tasks ?? defaultTasks();
199 const startedAt = new Date(now()).toISOString();
200 const results: AutopilotTaskResult[] = [];
201 for (const t of tasks) {
202 const t0 = now();
203 try {
204 await t.run();
205 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
206 } catch (err) {
207 const message =
208 err instanceof Error ? err.message : String(err ?? "unknown error");
209 console.error(`[autopilot] ${t.name}: ${message}`);
210 results.push({
211 name: t.name,
212 ok: false,
213 durationMs: now() - t0,
214 error: message,
215 });
216 }
217 }
218 const finishedAt = new Date(now()).toISOString();
219 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
220 const okCount = results.filter((r) => r.ok).length;
221 console.log(
222 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
223 );
224 return { startedAt, finishedAt, tasks: results };
225}
226
227/** Exposed for unit tests. */
228export const __test = {
229 resolveIntervalMs,
230 processMergeQueues,
231 rescanAdvisoriesBatch,
232 DEFAULT_INTERVAL_MS,
233 ADVISORY_RESCAN_BATCH,
234};
Modifiedsrc/routes/web.tsx+2−19View fileUnifiedSplit
4747import { softAuth, requireAuth } from "../middleware/auth";
4848import type { AuthEnv } from "../middleware/auth";
4949import { trackByName } from "../lib/traffic";
50import { LandingPage } from "../views/landing";
5051
5152const web = new Hono<AuthEnv>();
5253
6364
6465 return c.html(
6566 <Layout user={null}>
66 <div class="empty-state">
67 <h2>gluecron</h2>
68 <p>AI-native code intelligence platform</p>
69 <div style="margin-top: 24px; display: flex; gap: 12px; justify-content: center">
70 <a href="/register" class="btn btn-primary">
71 Get started
72 </a>
73 <a href="/login" class="btn">
74 Sign in
75 </a>
76 </div>
77 <pre style="margin-top: 32px">{`# Quick start
78curl -X POST http://localhost:3000/api/setup \\
79 -H 'Content-Type: application/json' \\
80 -d '{"username":"you","email":"you@dev.com","repoName":"hello"}'
81
82git remote add gluecron http://localhost:3000/you/hello.git
83git push gluecron main`}</pre>
84 </div>
67 <LandingPage />
8568 </Layout>
8669 );
8770});
Addedsrc/views/landing.tsx+443−0View fileUnifiedSplit
1/**
2 * Marketing landing page for logged-out visitors.
3 *
4 * Pure presentational component — no props required. Drops into an existing
5 * <Layout user={null}> as a single fragment. All styles are scoped under the
6 * `landing-` class prefix so they don't leak into the rest of the app.
7 *
8 * Tone: confident, technical, specific. No "revolutionary", no "game-changing".
9 */
10
11import type { FC } from "hono/jsx";
12
13export interface LandingPageProps {
14 stats?: {
15 publicRepos?: number;
16 users?: number;
17 };
18}
19
20export const LandingPage: FC<LandingPageProps> = () => {
21 return (
22 <>
23 <style>{landingCss}</style>
24
25 {/* ---------- Hero ---------- */}
26 <section class="landing-hero">
27 <h1 class="landing-hero-title">
28 GitHub, but the AI actually ships the code.
29 </h1>
30 <p class="landing-hero-sub">
31 gluecron is a self-hostable code platform with AI review, dependency
32 updates, semantic search, and a workflow runner built in. No plugins.
33 No bolt-ons. One binary.
34 </p>
35 <div class="landing-hero-ctas">
36 <a href="/register" class="btn btn-primary landing-cta-primary">
37 Start free
38 </a>
39 <a href="/explore" class="btn landing-cta-secondary">
40 Explore public repos
41 </a>
42 </div>
43 <p class="landing-trust">
44 Self-hostable &middot; AI built in &middot; Open source mindset
45 </p>
46 </section>
47
48 {/* ---------- Features grid ---------- */}
49 <section class="landing-section">
50 <h2 class="landing-section-title">Everything in one binary</h2>
51 <p class="landing-section-sub">
52 The features GitHub sells as separate products (Dependabot, Copilot,
53 Actions, Advanced Security) ship with gluecron by default.
54 </p>
55 <div class="landing-grid">
56 <FeatureCard
57 icon="\u2728"
58 title="AI code review"
59 desc="Claude Sonnet reviews every PR with inline comments. Can block merges when configured in branch protection."
60 />
61 <FeatureCard
62 icon="\u21BB"
63 title="AI dependency updates"
64 desc="Scans package.json, opens PRs with bump tables, writes the branch via git plumbing. Dependabot without the setup."
65 />
66 <FeatureCard
67 icon="\u2315"
68 title="Semantic code search"
69 desc="voyage-code-3 embeddings over every chunk, with lexical fallback. Finds code by intent, not just text."
70 />
71 <FeatureCard
72 icon="\u{1F4D6}"
73 title="Explain this codebase"
74 desc="One click gets you a per-commit cached Markdown tour of the repo. Onboarding that writes itself."
75 />
76 <FeatureCard
77 icon="\u2713"
78 title="Signed commit verification"
79 desc="GPG and SSH signatures, Issuer Fingerprint extraction, a green Verified badge on the commit list."
80 />
81 <FeatureCard
82 icon="\u21C4"
83 title="Merge queues + required checks"
84 desc="Serialised merges with re-test against the latest base. Per-branch required check matrix, enforced at merge."
85 />
86 <FeatureCard
87 icon="\u2699"
88 title="Self-hosted workflow runner"
89 desc="Actions-equivalent runner reads .gluecron/workflows/*.yml. Bun subprocesses, size-capped logs, per-step timeouts."
90 />
91 <FeatureCard
92 icon="\u{1F4E6}"
93 title="npm-protocol package registry"
94 desc="Publish, install, yank over the real npm protocol. PAT-auth via .npmrc. No separate service to run."
95 />
96 <FeatureCard
97 icon="\u{1F510}"
98 title="Enterprise SSO (OIDC)"
99 desc="Okta, Azure AD, Auth0, Google Workspace. Auth-code flow, state+nonce, optional email-domain allow-list."
100 />
101 </div>
102 </section>
103
104 {/* ---------- vs GitHub ---------- */}
105 <section class="landing-section landing-compare">
106 <h2 class="landing-section-title">gluecron vs GitHub</h2>
107 <p class="landing-section-sub">
108 Honest comparison. GitHub is excellent at what it does. gluecron is
109 built for teams that want AI and CI in one place, on infrastructure
110 they control.
111 </p>
112 <div class="landing-compare-grid">
113 <div class="landing-compare-col landing-compare-us">
114 <h3>gluecron</h3>
115 <ul>
116 <li><span class="landing-check">{"\u2713"}</span> Self-host on your own box</li>
117 <li><span class="landing-check">{"\u2713"}</span> AI review and completion included</li>
118 <li><span class="landing-check">{"\u2713"}</span> Dependency updater included</li>
119 <li><span class="landing-check">{"\u2713"}</span> Workflow runner included</li>
120 <li><span class="landing-check">{"\u2713"}</span> Semantic search included</li>
121 <li><span class="landing-check">{"\u2713"}</span> Green-by-default (gates, protection, codeowners)</li>
122 <li><span class="landing-check">{"\u2713"}</span> One binary. One database. One deploy.</li>
123 </ul>
124 </div>
125 <div class="landing-compare-col landing-compare-them">
126 <h3>GitHub</h3>
127 <ul>
128 <li><span class="landing-dash">{"\u2013"}</span> SaaS-first (Enterprise Server is separate)</li>
129 <li><span class="landing-dash">{"\u2013"}</span> Copilot billed per seat</li>
130 <li><span class="landing-dash">{"\u2013"}</span> Dependabot configured per repo</li>
131 <li><span class="landing-dash">{"\u2013"}</span> Actions minutes metered</li>
132 <li><span class="landing-dash">{"\u2013"}</span> Code search is lexical by default</li>
133 <li><span class="landing-dash">{"\u2013"}</span> Advanced Security is an add-on</li>
134 <li><span class="landing-dash">{"\u2013"}</span> Many moving pieces to wire together</li>
135 </ul>
136 </div>
137 </div>
138 </section>
139
140 {/* ---------- How it works ---------- */}
141 <section class="landing-section">
142 <h2 class="landing-section-title">How it works</h2>
143 <div class="landing-steps">
144 <div class="landing-step">
145 <div class="landing-step-num">1</div>
146 <h3>Push code</h3>
147 <p>
148 <code>git push</code> to gluecron. Standard Smart HTTP. No agent
149 to install.
150 </p>
151 </div>
152 <div class="landing-step">
153 <div class="landing-step-num">2</div>
154 <h3>Gates + AI review run</h3>
155 <p>
156 Secret scanner, security gate, AI reviewer, and your workflows
157 fire automatically. Rulesets enforce push policy.
158 </p>
159 </div>
160 <div class="landing-step">
161 <div class="landing-step-num">3</div>
162 <h3>Green pushes auto-deploy</h3>
163 <p>
164 Default-branch commits that pass all gates deploy through
165 Crontech. Failed deploys open an AI-authored incident issue.
166 </p>
167 </div>
168 </div>
169 </section>
170
171 {/* ---------- Final CTA band ---------- */}
172 <section class="landing-cta-band">
173 <h2>Ready to push?</h2>
174 <p>
175 Create an account, push a repo, watch the gates run. No credit card,
176 no trial clock.
177 </p>
178 <div class="landing-hero-ctas">
179 <a href="/register" class="btn btn-primary landing-cta-primary">
180 Start free
181 </a>
182 <a href="/explore" class="btn landing-cta-secondary">
183 Browse public repos
184 </a>
185 </div>
186 </section>
187
188 {/* ---------- Footer row ---------- */}
189 <section class="landing-foot">
190 <a href="/explore">Explore</a>
191 <span class="landing-foot-sep">&middot;</span>
192 <a href="/marketplace">Marketplace</a>
193 <span class="landing-foot-sep">&middot;</span>
194 <a href="/api/graphql">GraphQL API</a>
195 <span class="landing-foot-sep">&middot;</span>
196 <a href="/shortcuts">Keyboard shortcuts</a>
197 <span class="landing-foot-sep">&middot;</span>
198 <a href="/terms">Terms</a>
199 </section>
200 </>
201 );
202};
203
204const FeatureCard: FC<{ icon: string; title: string; desc: string }> = ({
205 icon,
206 title,
207 desc,
208}) => (
209 <div class="landing-feature">
210 <div class="landing-feature-icon" aria-hidden="true">
211 {icon}
212 </div>
213 <h3 class="landing-feature-title">{title}</h3>
214 <p class="landing-feature-desc">{desc}</p>
215 </div>
216);
217
218const landingCss = `
219 /* ---------- Hero ---------- */
220 .landing-hero {
221 padding: 72px 16px 56px;
222 text-align: center;
223 max-width: 860px;
224 margin: 0 auto;
225 border-bottom: 1px solid var(--border);
226 }
227 .landing-hero-title {
228 font-size: 44px;
229 line-height: 1.1;
230 letter-spacing: -0.02em;
231 margin: 0 0 20px;
232 color: var(--text);
233 font-weight: 700;
234 }
235 .landing-hero-sub {
236 font-size: 18px;
237 color: var(--text-muted);
238 margin: 0 auto 32px;
239 max-width: 640px;
240 line-height: 1.5;
241 }
242 .landing-hero-ctas {
243 display: flex;
244 gap: 12px;
245 justify-content: center;
246 flex-wrap: wrap;
247 margin-bottom: 16px;
248 }
249 .landing-cta-primary,
250 .landing-cta-secondary {
251 padding: 10px 20px;
252 font-size: 15px;
253 font-weight: 600;
254 }
255 .landing-trust {
256 font-size: 13px;
257 color: var(--text-muted);
258 margin-top: 12px;
259 letter-spacing: 0.02em;
260 }
261
262 /* ---------- Section scaffolding ---------- */
263 .landing-section {
264 padding: 64px 16px;
265 max-width: 1080px;
266 margin: 0 auto;
267 border-bottom: 1px solid var(--border);
268 }
269 .landing-section-title {
270 font-size: 28px;
271 font-weight: 700;
272 margin: 0 0 12px;
273 color: var(--text);
274 letter-spacing: -0.01em;
275 }
276 .landing-section-sub {
277 font-size: 15px;
278 color: var(--text-muted);
279 margin: 0 0 36px;
280 max-width: 680px;
281 line-height: 1.6;
282 }
283
284 /* ---------- Features grid ---------- */
285 .landing-grid {
286 display: grid;
287 grid-template-columns: repeat(3, 1fr);
288 gap: 16px;
289 }
290 .landing-feature {
291 border: 1px solid var(--border);
292 border-radius: var(--radius);
293 padding: 20px;
294 background: var(--bg-secondary);
295 transition: border-color 0.15s;
296 }
297 .landing-feature:hover { border-color: var(--text-muted); }
298 .landing-feature-icon {
299 font-size: 22px;
300 margin-bottom: 10px;
301 line-height: 1;
302 }
303 .landing-feature-title {
304 font-size: 15px;
305 font-weight: 600;
306 margin: 0 0 6px;
307 color: var(--text);
308 }
309 .landing-feature-desc {
310 font-size: 13px;
311 color: var(--text-muted);
312 line-height: 1.55;
313 margin: 0;
314 }
315
316 /* ---------- Compare strip ---------- */
317 .landing-compare-grid {
318 display: grid;
319 grid-template-columns: 1fr 1fr;
320 gap: 16px;
321 }
322 .landing-compare-col {
323 border: 1px solid var(--border);
324 border-radius: var(--radius);
325 padding: 24px;
326 background: var(--bg-secondary);
327 }
328 .landing-compare-us { border-color: var(--accent); }
329 .landing-compare-col h3 {
330 font-size: 18px;
331 font-weight: 700;
332 margin: 0 0 16px;
333 color: var(--text);
334 }
335 .landing-compare-col ul {
336 list-style: none;
337 margin: 0;
338 padding: 0;
339 }
340 .landing-compare-col li {
341 font-size: 14px;
342 color: var(--text);
343 padding: 6px 0;
344 line-height: 1.5;
345 }
346 .landing-check { color: var(--green); font-weight: 700; margin-right: 8px; }
347 .landing-dash { color: var(--text-muted); font-weight: 700; margin-right: 8px; }
348 .landing-compare-them li { color: var(--text-muted); }
349
350 /* ---------- How it works ---------- */
351 .landing-steps {
352 display: grid;
353 grid-template-columns: repeat(3, 1fr);
354 gap: 16px;
355 }
356 .landing-step {
357 border: 1px solid var(--border);
358 border-radius: var(--radius);
359 padding: 24px;
360 background: var(--bg-secondary);
361 }
362 .landing-step-num {
363 display: inline-flex;
364 align-items: center;
365 justify-content: center;
366 width: 28px;
367 height: 28px;
368 border-radius: 50%;
369 background: var(--accent);
370 color: #fff;
371 font-weight: 700;
372 font-size: 14px;
373 margin-bottom: 12px;
374 }
375 .landing-step h3 {
376 font-size: 16px;
377 font-weight: 600;
378 margin: 0 0 6px;
379 color: var(--text);
380 }
381 .landing-step p {
382 font-size: 13px;
383 color: var(--text-muted);
384 line-height: 1.55;
385 margin: 0;
386 }
387 .landing-step code {
388 font-family: var(--font-mono);
389 font-size: 12px;
390 background: var(--bg-tertiary);
391 padding: 1px 6px;
392 border-radius: 3px;
393 color: var(--text);
394 }
395
396 /* ---------- Final CTA band ---------- */
397 .landing-cta-band {
398 padding: 72px 16px;
399 text-align: center;
400 max-width: 860px;
401 margin: 0 auto;
402 border-bottom: 1px solid var(--border);
403 }
404 .landing-cta-band h2 {
405 font-size: 32px;
406 font-weight: 700;
407 margin: 0 0 12px;
408 color: var(--text);
409 }
410 .landing-cta-band p {
411 font-size: 15px;
412 color: var(--text-muted);
413 margin: 0 auto 28px;
414 max-width: 560px;
415 }
416
417 /* ---------- Foot row ---------- */
418 .landing-foot {
419 padding: 32px 16px 48px;
420 text-align: center;
421 font-size: 13px;
422 color: var(--text-muted);
423 }
424 .landing-foot a { color: var(--text-muted); }
425 .landing-foot a:hover { color: var(--text); text-decoration: none; }
426 .landing-foot-sep { margin: 0 10px; color: var(--border); }
427
428 /* ---------- Responsive ---------- */
429 @media (max-width: 820px) {
430 .landing-hero { padding: 48px 16px 40px; }
431 .landing-hero-title { font-size: 32px; }
432 .landing-hero-sub { font-size: 16px; }
433 .landing-section { padding: 48px 16px; }
434 .landing-section-title { font-size: 24px; }
435 .landing-grid { grid-template-columns: 1fr; }
436 .landing-compare-grid { grid-template-columns: 1fr; }
437 .landing-steps { grid-template-columns: 1fr; }
438 .landing-cta-band { padding: 48px 16px; }
439 .landing-cta-band h2 { font-size: 24px; }
440 }
441`;
442
443export default LandingPage;
0444