CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

chore(design): Wave 0 — fix the measuring instrument, delete 9,100 lines of dead views #5544

MergedXSccantynz wants to mergechore/design-wave0mainopened 6d ago
16 changed files+90−9192
Modifiedscripts/design-audit.mjs+47−5View fileUnifiedSplit
2727const EXEMPT_MARK = 'design-audit-exempt';
2828
2929const HEX = /#[0-9a-fA-F]{3,8}\b/g;
30// Purple/indigo/violet detection: hex → hue in [250, 300).
30
31/**
32 * Strip the text the color regex must never see (2026-08-27 instrument
33 * fix — the audit was wrong in both directions):
34 * - block comments (incl. JSX comments): digest.tsx scored 3 "purples"
35 * that lived in a comment DESCRIBING a purple already removed;
36 * - whole-line // comments (never mid-line — that would eat URLs);
37 * - HTML numeric entities: → (an arrow glyph) parses as hex 859
38 * → hue 270 → "banned purple".
39 */
40function stripNonColorText(text) {
41 return text
42 .replace(/\/\*[\s\S]*?\*\//g, '')
43 .replace(/^\s*\/\/.*$/gm, '')
44 .replace(/&#x?[0-9a-fA-F]+;/g, '');
45}
46
47/**
48 * 3/4-digit candidates are ambiguous: "#204" is a hex color in CSS and a
49 * PR number in prose (pr-redesign scored issue numbers as colors). Count
50 * them only when the line reads like styling; 6/8-digit forms always count.
51 */
52const CSSISH =
53 /color|background|border|fill|stroke|shadow|gradient|outline|style=|--[a-z]/i;
54function colorMatches(text) {
55 const out = [];
56 for (const line of text.split('\n')) {
57 for (const m of line.match(HEX) ?? []) {
58 if (m.length >= 7 || CSSISH.test(line)) out.push(m);
59 }
60 }
61 return out;
62}
63
64// Purple/indigo/violet detection: hex → hue in [225, 305).
3165function hueOf(hex) {
3266 let h = hex.slice(1);
3367 if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join('');
4377 else if (max === g) hue = (b - r) / d + 2;
4478 else hue = (r - g) / d + 4;
4579 hue = (hue * 60 + 360) % 360;
46 // Low-saturation near-grays are palette-neutral, not "purple".
80 // Low-saturation near-grays are palette-neutral, not "purple". HSL
81 // saturation alone is not enough: near-black navy backgrounds (#0a0c12,
82 // chroma 0.03) read as s≈0.29 and scored as "banned purple" — they are
83 // a dark-slab consistency problem, not a purple one. Chroma (max−min)
84 // is the perceptual gate: below 0.10 the eye sees a neutral.
4785 const l = (max + min) / 2;
4886 const s = d / (1 - Math.abs(2 * l - 1) || 1);
49 if (s < 0.15) return null;
87 if (s < 0.15 || d < 0.10) return null;
5088 return hue;
5189}
5290const isBanned = (hex) => {
5391 const hue = hueOf(hex);
54 return hue !== null && hue >= 250 && hue < 300;
92 // 225–305: the window design-tokens.ts actually documents ("indigo and
93 // violet"). The original 250–300 gate let indigo (#a5b4fc h230, #3730a3
94 // h244, #1e40af h226) sail through — a dozen live purples were invisible
95 // to the instrument that existed to catch them.
96 return hue !== null && hue >= 225 && hue < 305;
5597};
5698
5799const files = [];
72114 const text = readFileSync(f, 'utf8');
73115 if (text.includes(EXEMPT_MARK)) continue;
74116 const emailExempt = EMAIL_EXEMPT.test(norm) && /<html|<table|mso-/i.test(text);
75 const hexes = text.match(HEX) ?? [];
117 const hexes = colorMatches(stripNonColorText(text));
76118 if (hexes.length === 0) continue;
77119 const banned = hexes.filter(isBanned);
78120 if (!emailExempt) bannedTotal += banned.length;
Modifiedsrc/__tests__/error-beacon-coverage.test.ts+3−5View fileUnifiedSplit
2323 * Views that render their own document and are deliberately NOT instrumented,
2424 * with the reason. Anything else self-documenting is a bug.
2525 */
26const EXEMPT: Record<string, string> = {
27 "landing-2030.tsx":
28 "dead code — web.tsx `void`s it and serves landing-pro instead",
29 "landing.tsx": "dead code — `void`ed alongside landing-2030",
30};
26// 2026-08-27: the two long-standing entries (landing.tsx, landing-2030.tsx)
27// were dead code and are now deleted rather than exempted.
28const EXEMPT: Record<string, string> = {};
3129
3230function rendersOwnDocument(src: string): boolean {
3331 return /<!doctype html|<html\s+lang=/i.test(src);
Modifiedsrc/__tests__/landing-hero.test.ts+2−4View fileUnifiedSplit
9696 // setup (publicStats falsy) — but the section's HTML class string
9797 // is the stable identifier we assert when it IS present, which it
9898 // will be whenever the lazy computePublicStats() returns a payload.
99 // Either way, the L4 builder export must still exist + be wired.
100 const { buildSocialProofTiles } = await import("../views/landing");
101 expect(typeof buildSocialProofTiles).toBe("function");
102
99 // (The legacy L4 builder lived in views/landing.tsx — dead code
100 // deleted 2026-08-27; the markup assertions below are the contract.)
103101 // If the conditional rendered, the class is in the markup. If it
104102 // didn't render, the COUNTERS animation script string isn't either —
105103 // both signals stay aligned, so a regression that DROPS the section
Modifiedsrc/__tests__/landing-live-feed.test.ts+20−265View fileUnifiedSplit
11/**
2 * Block M1Live-now landing feed tests.
2 * Landing route + live-feed integration.
33 *
4 * Covers:
5 * - The new "Live now" section renders with SSR data
6 * - Empty-state copy renders when all four endpoints return empty
7 * - The inline poller script is present + references the four
8 * `/api/v2/demo/*` endpoints + `setInterval`
9 * - `relativeTimeFromNow` handles <60s, <60m, <24h, >24h, future, NaN,
10 * null, undefined, Date, ISO string, number
4 * History: this file was Block M1 — component-level SSR tests for the
5 * legacy `LandingPage`'s "Live now" section plus its
6 * `relativeTimeFromNow` helper. That view (and landing-2030) were dead
7 * code — `void`ed in web.tsx while `/` served LandingProPage — and were
8 * deleted on 2026-08-27 in the design Wave 0 cleanup. Testing a deleted
9 * component proves nothing, so those blocks went with it; what remains
10 * is the contract that still matters: `GET /` responds 200 with the
11 * live landing's hero + register CTA, with the demo-activity feed
12 * helpers stubbed.
1113 *
1214 * DB-mocking strategy: spread-from-real (K1 pattern). We capture the
1315 * real `../lib/demo-activity` module *before* installing `mock.module`
1416 * so every other export keeps working, then restore in `afterAll`.
15 * The five listing helpers are replaced with deterministic stubs that
16 * a test can mutate via `setLiveFeedStubs`.
1717 */
1818
1919import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
20import {
21 relativeTimeFromNow,
22 LandingPage,
23 type LandingLiveFeed,
24} from "../views/landing";
25
26// ---------------------------------------------------------------------
27// Spread-from-real stubs for the demo-activity helpers.
28// ---------------------------------------------------------------------
2920
3021const _real_demoActivity = await import("../lib/demo-activity");
3122
7869 _stubFeed = [];
7970});
8071
81// ---------------------------------------------------------------------
82// SSR fixtures — used by the JSX-level renders below.
83// ---------------------------------------------------------------------
84
85const FIVE_MIN_AGO = new Date(Date.now() - 5 * 60 * 1000);
86const TWO_HOUR_AGO = new Date(Date.now() - 2 * 60 * 60 * 1000);
87
88const FULL_LIVE_FEED: LandingLiveFeed = {
89 queued: [
90 {
91 repo: "todo-api",
92 number: 42,
93 title: "Add JSON export",
94 createdAt: FIVE_MIN_AGO,
95 },
96 {
97 repo: "hello-python",
98 number: 7,
99 title: "Wire up CLI flag",
100 createdAt: FIVE_MIN_AGO,
101 },
102 ],
103 merges: [
104 {
105 repo: "todo-api",
106 number: 88,
107 title: "Fix flake in test",
108 mergedAt: FIVE_MIN_AGO,
109 },
110 ],
111 reviews: [
112 {
113 repo: "todo-api",
114 prNumber: 91,
115 commentSnippet: "Looks good, minor nit on naming.",
116 createdAt: TWO_HOUR_AGO,
117 },
118 ],
119 reviewCount: 47,
120 feed: [
121 {
122 kind: "auto_merge.merged",
123 repo: "todo-api",
124 ref: { type: "pr", number: 88 },
125 at: FIVE_MIN_AGO,
126 },
127 {
128 kind: "ai_review.posted",
129 repo: "todo-api",
130 ref: { type: "pr", number: 91 },
131 at: TWO_HOUR_AGO,
132 },
133 {
134 kind: "ai_build.dispatched",
135 repo: "hello-python",
136 ref: { type: "issue", number: 7 },
137 at: FIVE_MIN_AGO,
138 },
139 ],
140};
141
142const EMPTY_LIVE_FEED: LandingLiveFeed = {
143 queued: [],
144 merges: [],
145 reviews: [],
146 reviewCount: 0,
147 feed: [],
148};
149
150// `LandingPage` is a JSX function component; calling it directly returns
151// a hono/jsx node whose `.toString()` is the rendered HTML. We avoid
152// JSX literals in this `.ts` test file (Bun's test loader only enables
153// JSX parsing for `.tsx`) and instead invoke the component as a plain
154// function — semantically identical, no JSX transform required.
155async function renderLanding(props: {
156 liveFeed?: LandingLiveFeed | null;
157}): Promise<string> {
158 // hono/jsx components return a Promise<HtmlEscapedString> or an
159 // HtmlEscapedString — either way, awaiting + String() yields HTML.
160 // eslint-disable-next-line @typescript-eslint/no-explicit-any
161 const node: any = await (LandingPage as any)(props);
162 return String(node);
163}
164
165// ---------------------------------------------------------------------
166// Tests
167// ---------------------------------------------------------------------
168
169describe("Block M1 — Live-now section SSR", () => {
170 it("renders the Live now heading + pulse + endpoint script when liveFeed is present", async () => {
171 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
172 expect(html).toContain("Live now");
173 expect(html).toContain(
174 "Claude is working on demo repos as you read this."
175 );
176 // Pulse indicator class is the cheap "is the live block here" tell.
177 expect(html).toContain("landing-livenow-pulse");
178 expect(html).toContain("landing-livenow-grid");
179 });
180
181 it("renders the four card titles", async () => {
182 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
183 expect(html).toContain("Issues queued for AI");
184 expect(html).toContain("Recently merged by AI");
185 expect(html).toContain("AI reviews posted");
186 expect(html).toContain("Activity feed");
187 });
188
189 it("renders the queued issue + merge + review rows from the SSR snapshot", async () => {
190 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
191 expect(html).toContain("#42");
192 expect(html).toContain("Add JSON export");
193 expect(html).toContain("#88");
194 expect(html).toContain("Fix flake in test");
195 expect(html).toContain("#91");
196 expect(html).toContain("Looks good, minor nit on naming.");
197 // Review count visible.
198 expect(html).toContain("47");
199 expect(html).toContain("reviews today");
200 });
201
202 it("renders the activity feed entries with their kind labels", async () => {
203 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
204 expect(html).toContain("auto-merged");
205 expect(html).toContain("AI review posted");
206 expect(html).toContain("AI-build queued");
207 });
208
209 it("renders the CTA banner at the bottom of the section", async () => {
210 const html = await renderLanding({ liveFeed: FULL_LIVE_FEED });
211 expect(html).toContain("Want this for your repos?");
212 expect(html).toContain('href="/register"');
213 expect(html).toContain('href="/demo"');
214 });
215
216 it("renders friendly empty states when all four endpoints return empty", async () => {
217 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
218 // The block still renders.
219 expect(html).toContain("Live now");
220 expect(html).toContain("landing-livenow-grid");
221 // Empty-state copy for each card.
222 expect(html).toContain("No queued AI builds");
223 expect(html).toContain("No auto-merges in the last 24h");
224 expect(html).toContain("No AI reviews in the last 24h");
225 expect(html).toContain("Quiet right now");
226 // Big-number count falls back to 0.
227 expect(html).toMatch(/data-tick-target="0"/);
228 });
229
230 it("still renders the live section when liveFeed is absent (null)", async () => {
231 const html = await renderLanding({ liveFeed: null });
232 expect(html).toContain("Live now");
233 expect(html).toContain("No queued AI builds");
234 });
235});
236
237describe("Block M1 — inline poller script", () => {
238 it("contains setInterval + the four /api/v2/demo/* endpoint URLs", async () => {
239 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
240 expect(html).toContain("setInterval");
241 expect(html).toContain("/api/v2/demo/queued");
242 expect(html).toContain("/api/v2/demo/merges");
243 expect(html).toContain("/api/v2/demo/reviews");
244 expect(html).toContain("/api/v2/demo/activity");
245 });
246
247 it("refreshes on visibilitychange (tab focus)", async () => {
248 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
249 expect(html).toContain("visibilitychange");
250 expect(html).toContain("visibilityState");
251 });
252
253 it("ticks numbers + flashes new rows", async () => {
254 const html = await renderLanding({ liveFeed: EMPTY_LIVE_FEED });
255 // The implementation labels for the two motion effects.
256 expect(html).toContain("tickNumber");
257 expect(html).toContain("flashRow");
258 expect(html).toContain("landing-livecard-flash");
259 });
260});
261
262describe("Block M1 — relativeTimeFromNow helper edges", () => {
263 const NOW = 1_700_000_000_000;
264
265 it("returns 'just now' for deltas under 60 seconds", () => {
266 expect(relativeTimeFromNow(NOW - 5_000, NOW)).toBe("just now");
267 expect(relativeTimeFromNow(NOW - 59_000, NOW)).toBe("just now");
268 expect(relativeTimeFromNow(NOW, NOW)).toBe("just now");
269 });
270
271 it("returns 'about N minutes ago' under an hour", () => {
272 expect(relativeTimeFromNow(NOW - 60_000, NOW)).toBe("about 1 minute ago");
273 expect(relativeTimeFromNow(NOW - 5 * 60_000, NOW)).toBe(
274 "about 5 minutes ago"
275 );
276 expect(relativeTimeFromNow(NOW - 59 * 60_000, NOW)).toBe(
277 "about 59 minutes ago"
278 );
279 });
280
281 it("returns 'about N hours ago' under a day", () => {
282 expect(relativeTimeFromNow(NOW - 60 * 60_000, NOW)).toBe(
283 "about 1 hour ago"
284 );
285 expect(relativeTimeFromNow(NOW - 23 * 60 * 60_000, NOW)).toBe(
286 "about 23 hours ago"
287 );
288 });
289
290 it("returns 'about N days ago' beyond 24h", () => {
291 expect(relativeTimeFromNow(NOW - 24 * 60 * 60_000, NOW)).toBe(
292 "about 1 day ago"
293 );
294 expect(relativeTimeFromNow(NOW - 3 * 24 * 60 * 60_000, NOW)).toBe(
295 "about 3 days ago"
296 );
297 });
298
299 it("treats future timestamps as 'just now' (clock skew tolerance)", () => {
300 expect(relativeTimeFromNow(NOW + 5_000, NOW)).toBe("just now");
301 expect(relativeTimeFromNow(NOW + 10 * 60_000, NOW)).toBe("just now");
302 });
303
304 it("treats NaN / null / undefined / unparseable strings as 'just now'", () => {
305 expect(relativeTimeFromNow(NaN, NOW)).toBe("just now");
306 expect(relativeTimeFromNow(null, NOW)).toBe("just now");
307 expect(relativeTimeFromNow(undefined, NOW)).toBe("just now");
308 expect(relativeTimeFromNow("not a date", NOW)).toBe("just now");
309 });
310
311 it("accepts ISO strings + Date instances", () => {
312 expect(
313 relativeTimeFromNow(new Date(NOW - 2 * 60_000).toISOString(), NOW)
314 ).toBe("about 2 minutes ago");
315 expect(relativeTimeFromNow(new Date(NOW - 60 * 60_000), NOW)).toBe(
316 "about 1 hour ago"
317 );
318 });
319});
320
321describe("Block M1 — landing route integration", () => {
322 // 2026-06-10: GET / now serves the self-contained Landing2030Page;
323 // the legacy LandingPage (and its Live-now block, covered above at the
324 // component level) no longer renders on the home route. The route
325 // integration contract is therefore: / responds 200 with the 2030 hero.
326 it("GET / renders the 2030 landing page", async () => {
72describe("landing route integration", () => {
73 it("GET / renders the live landing hero with a register CTA", async () => {
32774 setLiveFeedStubs({
32875 queued: [
32976 {
34592 expect(body).toContain("lp-hero-ctas");
34693 expect(body).toContain('href="/register"');
34794 });
95
96 it("GET / still renders when every feed helper returns empty", async () => {
97 const app = (await import("../app")).default;
98 const res = await app.request("/");
99 expect(res.status).toBe(200);
100 const body = await res.text();
101 expect(body).toContain("lp-hero-ctas");
102 });
348103});
Modifiedsrc/__tests__/playground.test.ts+5−2View fileUnifiedSplit
761761 new URL("../views/layout.tsx", import.meta.url),
762762 "utf8"
763763 );
764 // 2026-08-27: views/landing.tsx was dead code (void-imported while
765 // landing-pro served /) and is deleted; the /play funnel CTA now
766 // lives on the page users actually see.
764767 landingSrc = await fs.readFile(
765 new URL("../views/landing.tsx", import.meta.url),
768 new URL("../views/landing-pro.tsx", import.meta.url),
766769 "utf8"
767770 );
768771 appSrc = await fs.readFile(
797800 expect(layoutSrc).toContain("Save your work");
798801 });
799802
800 it("landing.tsx adds the tertiary /play CTA", () => {
803 it("the live landing (landing-pro.tsx) carries the /play CTA", () => {
801804 expect(landingSrc).toContain('href="/play"');
802805 expect(landingSrc).toContain("without signing up");
803806 });
Modifiedsrc/__tests__/public-stats.test.ts+3−37View fileUnifiedSplit
2424 type PublicStats,
2525 type PublicStatsDeps,
2626} from "../lib/public-stats";
27import { buildSocialProofTiles } from "../views/landing";
2827
2928// ---------------------------------------------------------------------------
3029// Test helpers
322321 });
323322});
324323
325// ---------------------------------------------------------------------------
326// 8. Tile builder — exact render order + label text.
327// ---------------------------------------------------------------------------
328
329describe("buildSocialProofTiles", () => {
330 it("emits six tiles in the documented render order", () => {
331 const stats: PublicStats = {
332 totalPublicRepos: 41,
333 totalUsers: 1023,
334 totalPublicPullRequests: 88,
335 totalPublicIssues: 132,
336 weeklyPrsAutoMerged: 12,
337 weeklyIssuesBuiltByAi: 5,
338 weeklyAiReviewsPosted: 47,
339 weeklySecretsAutoFixed: 1,
340 weeklyDeploysShipped: 19,
341 weeklyHoursSaved: 23.4,
342 asOf: new Date(),
343 };
344 const tiles = buildSocialProofTiles(stats);
345 expect(tiles).toHaveLength(6);
346 expect(tiles[0]!.value).toBe(41);
347 expect(tiles[0]!.label).toMatch(/public repos/i);
348 expect(tiles[1]!.value).toBe(1023);
349 expect(tiles[1]!.label).toMatch(/developers/i);
350 expect(tiles[2]!.value).toBe(12);
351 expect(tiles[2]!.label).toMatch(/auto-merged/i);
352 expect(tiles[3]!.value).toBe(5);
353 expect(tiles[3]!.label).toMatch(/issues built by ai/i);
354 expect(tiles[4]!.value).toBe(19);
355 expect(tiles[4]!.label).toMatch(/deploys/i);
356 expect(tiles[5]!.value).toBe(23); // 23.4 → rounded for the tile
357 expect(tiles[5]!.prefix).toBe("~");
358 expect(tiles[5]!.suffix).toBe("h");
359 });
360});
324// (Section 8, the buildSocialProofTiles render-order test, was removed
325// 2026-08-27: the builder lived in views/landing.tsx — dead code deleted
326// in the design Wave 0 cleanup; nothing live renders those tiles.)
Modifiedsrc/routes/web.tsx+0−4View fileUnifiedSplit
7777import { isAiAvailable } from "../lib/ai-client";
7878import { trackByName } from "../lib/traffic";
7979import { readTheme } from "./theme";
80import { LandingPage } from "../views/landing";
81import { Landing2030Page } from "../views/landing-2030";
8280import { LandingProPage, type LandingProLiveFeed } from "../views/landing-pro";
8381import { listPlans } from "../lib/billing";
8482import { computePublicStats, type PublicStats } from "../lib/public-stats";
19911989 liveFeed = null;
19921990 }
19931991
1994 void LandingPage;
1995 void Landing2030Page;
19961992 // Same plan rows /pricing renders — the home page must never quote a
19971993 // different price than the pricing page.
19981994 const plans = await listPlans().catch(() => null);
Modifiedsrc/views/distribution.tsx+4−1View fileUnifiedSplit
5151 <div class="di-eyebrow">01 · The IDE invasion</div>
5252 <h2 class="di-card-title">A full Gluecron client in your editor sidebar.</h2>
5353 <p class="di-card-desc">PR live sessions, AI commit messages, issue tracking, agent supervision — VS Code and JetBrains extensions with zero context switching. The browser becomes optional.</p>
54 <a href="/ide-multiplayer" class="di-card-link">See a live editor session →</a>
54 {/* No link: this pointed at /ide-multiplayer, a route that
55 never existed (the view was unrouted dead code, deleted
56 2026-08-27). A promise with a 404 behind it is worse
57 than no promise. */}
5558 </div>
5659 <div class="di-preview di-preview-light di-preview-ide">
5760 <div class="di-code-comment">// VS Code · Gluecron sidebar</div>
Deletedsrc/views/fleet-refactor.tsx+0−848View fileUnifiedSplit
1import type { FC } from "hono/jsx";
2
3export interface FleetItem {
4 repo: string;
5 pr: string;
6 added: number;
7 removed: number;
8 gates: string;
9 sandboxUrl: string;
10 status: string;
11}
12
13export interface FleetRefactorProps {
14 fleet?: FleetItem[];
15 user?: any;
16}
17
18const DEFAULT_FLEET: FleetItem[] = [
19 {
20 repo: "gluecron",
21 pr: "PR №220 · feat/auth-header",
22 added: 34,
23 removed: 30,
24 gates: "4/4 green",
25 sandboxUrl: "pr-220.preview",
26 status: "Ready",
27 },
28 {
29 repo: "gluecron-cli",
30 pr: "PR №221 · feat/auth-header",
31 added: 12,
32 removed: 12,
33 gates: "3/3 green",
34 sandboxUrl: "pr-221.preview",
35 status: "Ready",
36 },
37 {
38 repo: "editor-extensions",
39 pr: "PR №222 · feat/auth-header",
40 added: 18,
41 removed: 16,
42 gates: "4/5 running",
43 sandboxUrl: "pr-222.preview",
44 status: "Gating",
45 },
46 {
47 repo: "gatetest",
48 pr: "PR №223 · feat/auth-header",
49 added: 6,
50 removed: 6,
51 gates: "rebasing",
52 sandboxUrl: "pr-223.preview",
53 status: "Rebasing",
54 },
55];
56
57export const FleetRefactor: FC<FleetRefactorProps> = (props) => {
58 const fleet = props.fleet ?? DEFAULT_FLEET;
59 const user = props.user;
60 const avatarLabel = user?.username?.[0]?.toUpperCase() ?? "C";
61
62 return (
63 <>
64 <style dangerouslySetInnerHTML={{ __html: css }} />
65 <div class="fr-root">
66 <header class="fr-header">
67 <a href="/" class="fr-logo">
68 <span class="fr-logo-mark" />
69 gluecron
70 </a>
71 <span class="fr-header-sep">/</span>
72 <span class="fr-header-breadcrumb">Refactors</span>
73 <div class="fr-header-right">
74 <span class="fr-avatar">{avatarLabel}</span>
75 </div>
76 </header>
77
78 <main class="fr-main">
79 <div class="fr-hero">
80 <div class="fr-hero-text">
81 <div class="fr-eyebrow">Fleet refactor №7</div>
82 <h1 class="fr-title">Rename API auth header across every repo</h1>
83 <p class="fr-subtitle">
84 One spec, four repos, one reviewed changeset. Each repo gets its own PR and sandbox; they merge together or not at all.
85 </p>
86 </div>
87 <div class="fr-hero-actions">
88 <a href="#" class="fr-btn-secondary">Review combined diff</a>
89 <button
90 type="button"
91 id="fr-merge-btn"
92 class="fr-btn-primary fr-btn-disabled"
93 data-ready="false"
94 data-merged="false"
95 >
96 Waiting on gates…
97 </button>
98 </div>
99 </div>
100
101 <div class="fr-board">
102 <div class="fr-board-header">
103 <span>Repository</span>
104 <span>Changes</span>
105 <span>Gates</span>
106 <span>Sandbox</span>
107 <span>Status</span>
108 </div>
109
110 {fleet.map((f, i) => (
111 <div class="fr-board-row" data-row-index={String(i)}>
112 <span class="fr-repo-cell">
113 <span class="fr-repo-name">{f.repo}</span>
114 <span class="fr-pr-label">{f.pr}</span>
115 </span>
116 <span class="fr-diff-cell">
117 <span class="fr-diff-add">+{f.added}</span>{" "}
118 <span class="fr-diff-del">−{f.removed}</span>
119 </span>
120 <span class="fr-gate-cell" data-gate-index={String(i)}>
121 <span class="fr-gate-dot" data-dot-index={String(i)} />
122 <span class="fr-gate-text" data-gate-text={String(i)}>{f.gates}</span>
123 </span>
124 <a
125 href="#"
126 class="fr-sandbox-link"
127 >
128 {f.sandboxUrl}
129 </a>
130 <span
131 class="fr-status-chip"
132 data-status-index={String(i)}
133 >
134 {f.status}
135 </span>
136 </div>
137 ))}
138
139 <div class="fr-atomic-note">
140 <span class="fr-atomic-dot" id="fr-atomic-dot" />
141 <span class="fr-atomic-text" id="fr-atomic-text">
142 Atomic changeset: all four merge together, or none do. Waiting for 2 repos to finish gating.
143 </span>
144 </div>
145 </div>
146
147 <div class="fr-lower">
148 <section class="fr-diff-section">
149 <h2 class="fr-section-heading">The change — same pattern, every repo</h2>
150 <div class="fr-diff-card">
151 <div class="fr-diff-card-header">
152 <span>gluecron-cli · src/http.ts</span>
153 <span>
154 <span class="fr-diff-add">+2</span>{" "}
155 <span class="fr-diff-del">−2</span>
156 </span>
157 </div>
158 <div class="fr-diff-body">
159 <div class="fr-diff-line fr-diff-ctx">{" const headers = {"}</div>
160 <div class="fr-diff-line fr-diff-rem">{'− "X-Gluecron-Token": token,'}</div>
161 <div class="fr-diff-line fr-diff-ins">{'+ "Authorization": `Bearer $' + '{token}`,'}</div>
162 <div class="fr-diff-line fr-diff-ctx">{' "Content-Type": "application/json",'}</div>
163 <div class="fr-diff-line fr-diff-ctx">{" };"}</div>
164 </div>
165 </div>
166 <p class="fr-diff-note">
167 41 call sites matched across the fleet. The server accepts both headers for 30 days (compatibility shim in step 1), so repos can merge in any order without breaking each other — but the changeset still merges atomically by default.
168 </p>
169 </section>
170
171 <aside class="fr-aside">
172 <div class="fr-aside-block">
173 <h3 class="fr-aside-eyebrow">Conflicts</h3>
174 <div class="fr-aside-card">
175 <p class="fr-aside-body">
176 <strong class="fr-aside-strong">Zero, by design.</strong>{" "}
177 All four branches are continuously rebased in their sandboxes. A conflicting push to gluecron/main at 11:32 was absorbed and re-tested automatically — you never saw it.
178 </p>
179 </div>
180 </div>
181
182 <div class="fr-aside-block">
183 <h3 class="fr-aside-eyebrow">How this reverses</h3>
184 <div class="fr-aside-card">
185 <p class="fr-aside-body">
186 One click reverts all four merges together. The 30-day compatibility shim means a revert is safe at any point in the window.
187 </p>
188 </div>
189 </div>
190
191 <div class="fr-aside-footer">
192 <p class="fr-aside-footer-text">
193 Prefer to do one repo by hand? Detach it from the changeset and its PR becomes a normal manual PR — the rest still merge atomically.
194 </p>
195 </div>
196 </aside>
197 </div>
198 </main>
199 </div>
200
201 <script dangerouslySetInnerHTML={{
202 __html: `
203(function() {
204 var step = 0;
205 var merged = false;
206
207 var GREEN = "#1e7f5c";
208 var AMBER = "#b45309";
209 var BLUE = "var(--accent)";
210 var PULSE = "gcPulse 1.4s ease-in-out infinite";
211
212 function getRowState(rowIndex, step, merged) {
213 var green = GREEN, amber = AMBER, blue = BLUE;
214 var pulse = PULSE;
215 if (rowIndex === 0) {
216 return {
217 gates: "4/4 green",
218 gateDotColor: green,
219 gateAnim: "none",
220 gateTextColor: green,
221 status: merged ? "Merged" : "Ready",
222 statusColor: merged ? blue : green,
223 };
224 }
225 if (rowIndex === 1) {
226 return {
227 gates: "3/3 green",
228 gateDotColor: green,
229 gateAnim: "none",
230 gateTextColor: green,
231 status: merged ? "Merged" : "Ready",
232 statusColor: merged ? blue : green,
233 };
234 }
235 if (rowIndex === 2) {
236 var ready2 = step >= 1;
237 return {
238 gates: ready2 ? "5/5 green" : "4/5 running",
239 gateDotColor: ready2 ? green : amber,
240 gateAnim: ready2 ? "none" : pulse,
241 gateTextColor: ready2 ? green : amber,
242 status: merged ? "Merged" : ready2 ? "Ready" : "Gating",
243 statusColor: merged ? blue : ready2 ? green : amber,
244 };
245 }
246 if (rowIndex === 3) {
247 var ready3 = step >= 2;
248 return {
249 gates: ready3 ? "3/3 green" : "rebasing",
250 gateDotColor: ready3 ? green : amber,
251 gateAnim: ready3 ? "none" : pulse,
252 gateTextColor: ready3 ? green : amber,
253 status: merged ? "Merged" : ready3 ? "Ready" : "Rebasing",
254 statusColor: merged ? blue : ready3 ? green : amber,
255 };
256 }
257 return {};
258 }
259
260 function isAllReady(step) {
261 return step >= 2;
262 }
263
264 function render() {
265 var ready = isAllReady(step);
266
267 // Update each row
268 for (var i = 0; i < 4; i++) {
269 var state = getRowState(i, step, merged);
270
271 var dotEl = document.querySelector('[data-dot-index="' + i + '"]');
272 var gateTextEl = document.querySelector('[data-gate-text="' + i + '"]');
273 var statusEl = document.querySelector('[data-status-index="' + i + '"]');
274
275 if (dotEl) {
276 dotEl.style.background = state.gateDotColor;
277 dotEl.style.animation = state.gateAnim;
278 }
279 if (gateTextEl) {
280 gateTextEl.textContent = state.gates;
281 gateTextEl.style.color = state.gateTextColor;
282 }
283 if (statusEl) {
284 statusEl.textContent = state.status;
285 statusEl.style.color = state.statusColor;
286 }
287 }
288
289 // Atomic note
290 var atomicDot = document.getElementById('fr-atomic-dot');
291 var atomicText = document.getElementById('fr-atomic-text');
292 if (atomicDot) {
293 atomicDot.style.background = merged ? BLUE : ready ? GREEN : AMBER;
294 }
295 if (atomicText) {
296 if (merged) {
297 atomicText.textContent = "All four merged atomically at 12:41 — one revert restores the whole fleet.";
298 } else if (ready) {
299 atomicText.textContent = "Atomic changeset: all four merge together, or none do. Everything is green — one approval merges the fleet.";
300 } else {
301 atomicText.textContent = "Atomic changeset: all four merge together, or none do. Waiting for 2 repos to finish gating.";
302 }
303 }
304
305 // Merge button
306 var btn = document.getElementById('fr-merge-btn');
307 if (btn) {
308 if (merged) {
309 btn.textContent = "Fleet merged ✓";
310 btn.style.background = GREEN;
311 btn.style.cursor = "default";
312 btn.classList.remove('fr-btn-disabled');
313 btn.classList.add('fr-btn-merged');
314 } else if (ready) {
315 btn.textContent = "Approve & merge fleet";
316 btn.style.background = "#16181d";
317 btn.style.cursor = "pointer";
318 btn.classList.remove('fr-btn-disabled');
319 } else {
320 btn.textContent = "Waiting on gates…";
321 btn.style.background = "#9a9da8";
322 btn.style.cursor = "default";
323 btn.classList.add('fr-btn-disabled');
324 }
325 }
326 }
327
328 // Timer: advance step every 3.5s up to step 2
329 var timer = setInterval(function() {
330 if (step < 2) {
331 step++;
332 render();
333 } else {
334 clearInterval(timer);
335 }
336 }, 3500);
337
338 // Merge button click
339 document.addEventListener('DOMContentLoaded', function() {
340 render();
341 var btn = document.getElementById('fr-merge-btn');
342 if (btn) {
343 btn.addEventListener('click', function() {
344 if (isAllReady(step) && !merged) {
345 merged = true;
346 render();
347 }
348 });
349 }
350 });
351
352 // Run immediately if DOM already ready
353 if (document.readyState !== 'loading') {
354 render();
355 var btn = document.getElementById('fr-merge-btn');
356 if (btn) {
357 btn.addEventListener('click', function() {
358 if (isAllReady(step) && !merged) {
359 merged = true;
360 render();
361 }
362 });
363 }
364 }
365})();
366 `
367 }} />
368 </>
369 );
370};
371
372export default FleetRefactor;
373
374const css = `
375@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=Inter+Tight:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
376
377@keyframes gcPulse {
378 0%, 100% { opacity: 1; }
379 50% { opacity: 0.35; }
380}
381
382*, *::before, *::after {
383 box-sizing: border-box;
384}
385
386.fr-root {
387 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
388 font-size: 14px;
389 line-height: 1.55;
390 letter-spacing: -0.008em;
391 color: #16181d;
392 background: #fcfcfd;
393 min-height: 100vh;
394 display: flex;
395 flex-direction: column;
396 -webkit-font-smoothing: antialiased;
397 text-rendering: optimizeLegibility;
398}
399
400/* Header */
401.fr-header {
402 height: 56px;
403 border-bottom: 1px solid rgba(22,24,29,0.07);
404 background: #fcfcfd;
405 display: flex;
406 align-items: center;
407 padding: 0 28px;
408 gap: 20px;
409 position: sticky;
410 top: 0;
411 z-index: 50;
412}
413
414.fr-logo {
415 display: inline-flex;
416 align-items: center;
417 gap: 9px;
418 font-family: 'Inter Tight', sans-serif;
419 font-weight: 600;
420 font-size: 15px;
421 letter-spacing: -0.02em;
422 color: #16181d;
423 text-decoration: none;
424}
425
426.fr-logo-mark {
427 width: 16px;
428 height: 16px;
429 border-radius: 5px;
430 background: var(--accent);
431 display: inline-block;
432 flex-shrink: 0;
433}
434
435.fr-header-sep {
436 color: #c4c6cf;
437}
438
439.fr-header-breadcrumb {
440 font-size: 13px;
441 color: #16181d;
442 font-weight: 500;
443}
444
445.fr-header-right {
446 margin-left: auto;
447 display: flex;
448 align-items: center;
449 gap: 14px;
450}
451
452.fr-avatar {
453 width: 28px;
454 height: 28px;
455 border-radius: 50%;
456 background: #16181d;
457 color: #fff;
458 font-size: 11px;
459 font-weight: 600;
460 display: inline-flex;
461 align-items: center;
462 justify-content: center;
463 flex-shrink: 0;
464}
465
466/* Main */
467.fr-main {
468 flex: 1;
469 max-width: 1180px;
470 width: 100%;
471 margin: 0 auto;
472 padding: 44px 32px 80px;
473}
474
475/* Hero */
476.fr-hero {
477 display: flex;
478 align-items: flex-end;
479 justify-content: space-between;
480 gap: 24px;
481 flex-wrap: wrap;
482 margin-bottom: 36px;
483}
484
485.fr-hero-text {
486 max-width: 640px;
487}
488
489.fr-eyebrow {
490 font-family: 'JetBrains Mono', monospace;
491 font-size: 11px;
492 letter-spacing: 0.12em;
493 text-transform: uppercase;
494 color: #8a8d99;
495 margin-bottom: 10px;
496}
497
498.fr-title {
499 font-family: 'Inter Tight', sans-serif;
500 font-size: 28px;
501 font-weight: 600;
502 letter-spacing: -0.025em;
503 line-height: 1.15;
504 margin: 0 0 8px;
505 color: #111318;
506}
507
508.fr-subtitle {
509 font-size: 14px;
510 color: #6b7080;
511 margin: 0;
512 line-height: 1.6;
513}
514
515.fr-hero-actions {
516 display: flex;
517 gap: 10px;
518 align-items: center;
519 flex-shrink: 0;
520}
521
522.fr-btn-secondary {
523 padding: 8px 16px;
524 border-radius: 8px;
525 font-size: 13px;
526 font-weight: 500;
527 border: 1px solid rgba(22,24,29,0.12);
528 background: #ffffff;
529 color: #16181d;
530 text-decoration: none;
531 display: inline-block;
532 white-space: nowrap;
533 cursor: pointer;
534 font-family: inherit;
535 transition: border-color 0.15s;
536}
537
538.fr-btn-secondary:hover {
539 border-color: rgba(22,24,29,0.24);
540}
541
542.fr-btn-primary {
543 padding: 8px 16px;
544 border-radius: 8px;
545 font-size: 13px;
546 font-weight: 600;
547 border: 0;
548 background: #16181d;
549 color: #fff;
550 cursor: pointer;
551 font-family: inherit;
552 white-space: nowrap;
553 transition: background 0.2s, cursor 0.1s;
554}
555
556.fr-btn-disabled {
557 background: #9a9da8 !important;
558 cursor: default !important;
559}
560
561.fr-btn-merged {
562 background: #1e7f5c !important;
563 cursor: default !important;
564}
565
566/* Fleet board */
567.fr-board {
568 border: 1px solid rgba(22,24,29,0.07);
569 border-radius: 14px;
570 background: #ffffff;
571 overflow: hidden;
572 margin-bottom: 36px;
573}
574
575.fr-board-header {
576 display: grid;
577 grid-template-columns: minmax(150px,1.4fr) minmax(90px,0.9fr) minmax(120px,1.2fr) minmax(90px,0.9fr) minmax(120px,1fr);
578 gap: 12px;
579 padding: 10px 22px;
580 border-bottom: 1px solid rgba(22,24,29,0.07);
581 font-family: 'JetBrains Mono', monospace;
582 font-size: 10.5px;
583 letter-spacing: 0.1em;
584 text-transform: uppercase;
585 color: #8a8d99;
586}
587
588.fr-board-row {
589 display: grid;
590 grid-template-columns: minmax(150px,1.4fr) minmax(90px,0.9fr) minmax(120px,1.2fr) minmax(90px,0.9fr) minmax(120px,1fr);
591 gap: 12px;
592 padding: 15px 22px;
593 border-bottom: 1px solid rgba(22,24,29,0.05);
594 align-items: center;
595}
596
597.fr-board-row:last-of-type {
598 border-bottom: 1px solid rgba(22,24,29,0.05);
599}
600
601.fr-repo-cell {
602 min-width: 0;
603}
604
605.fr-repo-name {
606 display: block;
607 font-size: 13.5px;
608 font-weight: 500;
609 color: #16181d;
610}
611
612.fr-pr-label {
613 display: block;
614 font-family: 'JetBrains Mono', monospace;
615 font-size: 11px;
616 color: #8a8d99;
617 margin-top: 1px;
618}
619
620.fr-diff-cell {
621 font-family: 'JetBrains Mono', monospace;
622 font-size: 12px;
623 font-variant-numeric: tabular-nums;
624}
625
626.fr-diff-add {
627 color: #1e7f5c;
628}
629
630.fr-diff-del {
631 color: #b42318;
632}
633
634.fr-gate-cell {
635 display: inline-flex;
636 align-items: center;
637 gap: 7px;
638 font-size: 12.5px;
639}
640
641.fr-gate-dot {
642 width: 7px;
643 height: 7px;
644 border-radius: 50%;
645 background: #1e7f5c;
646 flex-shrink: 0;
647 display: inline-block;
648}
649
650.fr-gate-text {
651 color: #1e7f5c;
652}
653
654.fr-sandbox-link {
655 font-family: 'JetBrains Mono', monospace;
656 font-size: 11.5px;
657 color: var(--accent);
658 text-decoration: none;
659}
660
661.fr-sandbox-link:hover {
662 text-decoration: underline;
663}
664
665.fr-status-chip {
666 font-size: 12.5px;
667 font-weight: 500;
668 color: #1e7f5c;
669}
670
671.fr-atomic-note {
672 padding: 13px 22px;
673 display: flex;
674 align-items: center;
675 gap: 10px;
676 background: #fcfcfd;
677}
678
679.fr-atomic-dot {
680 width: 7px;
681 height: 7px;
682 border-radius: 50%;
683 background: #b45309;
684 flex-shrink: 0;
685 display: inline-block;
686 transition: background 0.3s;
687}
688
689.fr-atomic-text {
690 font-size: 12.5px;
691 color: #6b7080;
692}
693
694/* Lower two-column */
695.fr-lower {
696 display: grid;
697 grid-template-columns: minmax(0,1.5fr) minmax(300px,1fr);
698 gap: 44px;
699 align-items: start;
700}
701
702/* Diff section */
703.fr-diff-section {
704 /* left column */
705}
706
707.fr-section-heading {
708 font-family: 'Inter Tight', sans-serif;
709 font-size: 15px;
710 font-weight: 600;
711 margin: 0 0 16px;
712 color: #16181d;
713 letter-spacing: -0.015em;
714}
715
716.fr-diff-card {
717 border: 1px solid rgba(22,24,29,0.07);
718 border-radius: 12px;
719 overflow: hidden;
720 background: #ffffff;
721}
722
723.fr-diff-card-header {
724 padding: 9px 18px;
725 border-bottom: 1px solid rgba(22,24,29,0.07);
726 font-family: 'JetBrains Mono', monospace;
727 font-size: 11.5px;
728 color: #6b7080;
729 display: flex;
730 justify-content: space-between;
731 align-items: center;
732}
733
734.fr-diff-body {
735 font-family: 'JetBrains Mono', monospace;
736 font-size: 12px;
737 line-height: 1.75;
738 padding: 8px 0;
739}
740
741.fr-diff-line {
742 padding: 0 18px;
743 white-space: pre;
744}
745
746.fr-diff-ctx {
747 color: #8a8d99;
748}
749
750.fr-diff-rem {
751 background: rgba(180,35,24,0.05);
752 color: #b42318;
753}
754
755.fr-diff-ins {
756 background: rgba(30,127,92,0.06);
757 color: #1e7f5c;
758}
759
760.fr-diff-note {
761 font-size: 12.5px;
762 color: #8a8d99;
763 margin: 12px 0 0;
764 line-height: 1.6;
765}
766
767/* Aside */
768.fr-aside {
769 display: flex;
770 flex-direction: column;
771 gap: 32px;
772 position: sticky;
773 top: 92px;
774}
775
776.fr-aside-block {
777 /* individual block */
778}
779
780.fr-aside-eyebrow {
781 font-family: 'JetBrains Mono', monospace;
782 font-size: 11px;
783 letter-spacing: 0.12em;
784 text-transform: uppercase;
785 color: #8a8d99;
786 font-weight: 500;
787 margin: 0 0 14px;
788}
789
790.fr-aside-card {
791 border: 1px solid rgba(22,24,29,0.07);
792 border-radius: 12px;
793 background: #ffffff;
794 padding: 20px;
795}
796
797.fr-aside-body {
798 font-size: 13px;
799 color: #6b7080;
800 margin: 0;
801 line-height: 1.65;
802}
803
804.fr-aside-strong {
805 color: #16181d;
806 font-weight: 500;
807}
808
809.fr-aside-footer {
810 border-top: 1px solid rgba(22,24,29,0.07);
811 padding-top: 20px;
812}
813
814.fr-aside-footer-text {
815 font-size: 12.5px;
816 color: #8a8d99;
817 margin: 0;
818 line-height: 1.6;
819}
820
821@media (max-width: 900px) {
822 .fr-lower {
823 grid-template-columns: 1fr;
824 }
825 .fr-aside {
826 position: static;
827 }
828}
829
830@media (max-width: 700px) {
831 .fr-main {
832 padding: 28px 16px 60px;
833 }
834 .fr-board-header,
835 .fr-board-row {
836 grid-template-columns: 1fr 1fr;
837 gap: 8px;
838 }
839 .fr-board-header span:nth-child(n+3),
840 .fr-board-row > *:nth-child(n+3) {
841 display: none;
842 }
843 .fr-hero {
844 flex-direction: column;
845 align-items: flex-start;
846 }
847}
848`;
Deletedsrc/views/ide-multiplayer.tsx+0−773View fileUnifiedSplit
1import type { FC } from "hono/jsx";
2
3export interface IdeMultiplayerProps {
4 user?: any;
5 sessionId?: string;
6}
7
8const baseLines = [
9 { n: 148, text: " const session = await sandbox.attach(pr.id);", color: "#16181d", bg: "transparent", agentCursor: false },
10 { n: 149, text: "", color: "#16181d", bg: "transparent", agentCursor: false },
11 { n: 150, text: " // Bounded repair: never hold a sandbox forever.", color: "#8a8d99", bg: "transparent", agentCursor: false },
12 { n: 151, text: " for (let i = 0; i < MAX_REPAIR_ATTEMPTS; i++) {", color: "#2fbf83", bg: "rgba(30,127,92,0.05)", agentCursor: false },
13 { n: 152, text: " const patch = await attemptPatch(gate, session);", color: "#2fbf83", bg: "rgba(30,127,92,0.05)", agentCursor: false },
14 { n: 153, text: " if (await gates.rerun(session, patch)) break;", color: "#2fbf83", bg: "rgba(30,127,92,0.05)", agentCursor: false },
15 { n: 154, text: " }", color: "#2fbf83", bg: "rgba(30,127,92,0.05)", agentCursor: true },
16 { n: 155, text: "", color: "#16181d", bg: "transparent", agentCursor: false },
17 { n: 156, text: ' telemetry.record("repair.attempt", {', color: "#16181d", bg: "transparent", agentCursor: false },
18 { n: 157, text: " gate: gate.name,", color: "#16181d", bg: "transparent", agentCursor: false },
19 { n: 158, text: " attempts: i,", color: "#16181d", bg: "transparent", agentCursor: false },
20 { n: 159, text: " outcome: session.lastRun,", color: "#16181d", bg: "transparent", agentCursor: false },
21 { n: 160, text: " });", color: "#16181d", bg: "transparent", agentCursor: false },
22];
23
24const chatMessages = [
25 { who: "CC", avatarBg: "var(--accent)", text: "The loop needs an upper bound — a red gate could hold the sandbox forever." },
26 { who: "CL", avatarBg: "#1e7f5c", text: "Agreed. Capping at MAX_REPAIR_ATTEMPTS and threading the session through each attempt — typing it now, lines 151–154." },
27 { who: "CL", avatarBg: "#1e7f5c", text: "Done. Accept the suggestion and I'll re-run gate: tests in the sandbox." },
28];
29
30export const IdeMultiplayer: FC<IdeMultiplayerProps> = (props) => {
31 const { user, sessionId = "pr-218" } = props;
32 const initials = user?.username
33 ? user.username.slice(0, 2).toUpperCase()
34 : "CC";
35
36 return (
37 <>
38 <style dangerouslySetInnerHTML={{ __html: css }} />
39 <div class="im-root">
40 {/* Top nav */}
41 <header class="im-header">
42 <a href="/" class="im-logo">
43 <span class="im-logo-mark" />
44 gluecron
45 </a>
46 <span class="im-header-sep">/</span>
47 <span class="im-header-sub">IDE extension · VS Code</span>
48 <div class="im-header-right">
49 <span class="im-header-note">Side-by-side, in your editor — the browser is optional</span>
50 </div>
51 </header>
52
53 <main class="im-main">
54 {/* Page heading */}
55 <div class="im-intro">
56 <div class="im-eyebrow">Multiplayer session · {sessionId} sandbox</div>
57 <h1 class="im-h1">You and the agent, in the same file.</h1>
58 <p class="im-lead">The extension joins the PR sandbox as a live session. You see the agent's cursor, it sees your edits, and either of you can take the keyboard — no context switch, no browser.</p>
59 </div>
60
61 {/* Editor mock */}
62 <div class="im-window" id="im-window">
63 {/* Title bar */}
64 <div class="im-titlebar">
65 <span class="im-dot im-dot-r" />
66 <span class="im-dot im-dot-y" />
67 <span class="im-dot im-dot-g" />
68 <span class="im-titlebar-filename">auto-repair.ts — gluecron · sandbox {sessionId}</span>
69 <span class="im-titlebar-chips">
70 <span class="im-chip im-chip-you">
71 <span class="im-chip-avatar im-chip-avatar-you">{initials}</span>
72 you
73 </span>
74 <span class="im-chip im-chip-claude">
75 <span class="im-chip-avatar im-chip-avatar-claude">CL</span>
76 claude
77 </span>
78 </span>
79 </div>
80
81 {/* Body: code pane + session rail */}
82 <div class="im-body">
83 {/* Code pane */}
84 <div class="im-code-pane" id="im-code-pane">
85 {baseLines.map((line) => (
86 <div
87 class="im-code-line"
88 style={`background:${line.bg}`}
89 data-agent-line={line.agentCursor ? "true" : "false"}
90 >
91 <span class="im-line-num">{line.n}</span>
92 <span
93 class="im-line-text"
94 style={`color:${line.color}`}
95 data-is-green={line.color === "#2fbf83" ? "true" : "false"}
96 >
97 {line.text}
98 {line.agentCursor && (
99 <>
100 <span class="im-caret" />
101 <span class="im-caret-label">← claude</span>
102 </>
103 )}
104 </span>
105 </div>
106 ))}
107 </div>
108
109 {/* Session rail */}
110 <div class="im-rail">
111 {/* Status section */}
112 <div class="im-rail-status">
113 <div class="im-rail-label">Session</div>
114 <div class="im-status-rows">
115 <div class="im-status-row">
116 <span class="im-dot-status im-dot-green im-dot-pulse" />
117 <span class="im-status-text" id="im-agent-status">claude finished — 4 lines suggested</span>
118 </div>
119 <div class="im-status-row">
120 <span class="im-dot-status im-dot-green" />
121 <span class="im-status-secondary">sandbox {sessionId} · terminal shared</span>
122 </div>
123 <div class="im-status-row">
124 <span class="im-dot-status im-gate-dot" id="im-gate-dot" />
125 <span class="im-status-secondary" id="im-gate-status">gates ready to re-run on accept</span>
126 </div>
127 </div>
128 </div>
129
130 {/* Chat feed */}
131 <div class="im-chat-feed" id="im-chat-feed">
132 <div class="im-rail-label">Chat</div>
133 {chatMessages.map((msg) => (
134 <div class="im-chat-msg">
135 <span
136 class="im-chat-avatar"
137 style={`background:${msg.avatarBg}`}
138 >
139 {msg.who}
140 </span>
141 <p class="im-chat-text">{msg.text}</p>
142 </div>
143 ))}
144 </div>
145
146 {/* Message input */}
147 <div class="im-chat-input-row">
148 <input
149 class="im-chat-input"
150 placeholder="Message claude…"
151 id="im-msg-input"
152 type="text"
153 />
154 <button class="im-chat-send" type="button" id="im-msg-send">Send</button>
155 </div>
156 </div>
157 </div>
158
159 {/* Status bar */}
160 <div class="im-statusbar">
161 <span>⎇ feat/repair-sandbox-loop</span>
162 <span>gluecron: connected</span>
163 <span class="im-statusbar-right">
164 agent edits: suggested → you accept ·{" "}
165 <button
166 class="im-mode-toggle"
167 type="button"
168 id="im-mode-toggle"
169 >
170 switch to observe-only
171 </button>
172 </span>
173 </div>
174 </div>
175
176 {/* Manual note row */}
177 <div class="im-footer-note">
178 <p class="im-footer-text">
179 Don't want the agent typing? Set it to{" "}
180 <strong class="im-footer-strong">observe-only</strong> and it becomes a reviewer in your gutter — you keep the keyboard, it keeps the receipts. Works identically in JetBrains.
181 </p>
182 <a href="#" class="im-footer-link">Open this session on the web →</a>
183 </div>
184 </main>
185 </div>
186
187 <script dangerouslySetInnerHTML={{ __html: js }} />
188 </>
189 );
190};
191
192export default IdeMultiplayer;
193
194const js = `
195(function() {
196 var step = 3;
197 var agentMode = "suggest";
198
199 var agentStatusEl = document.getElementById("im-agent-status");
200 var gateDotEl = document.getElementById("im-gate-dot");
201 var gateStatusEl = document.getElementById("im-gate-status");
202 var modeToggleEl = document.getElementById("im-mode-toggle");
203 var msgInput = document.getElementById("im-msg-input");
204 var msgSend = document.getElementById("im-msg-send");
205 var chatFeed = document.getElementById("im-chat-feed");
206
207 function applyStep(s) {
208 if (!agentStatusEl) return;
209 if (s < 3) {
210 agentStatusEl.textContent = "claude is typing in auto-repair.ts";
211 } else {
212 agentStatusEl.textContent = "claude finished — 4 lines suggested";
213 }
214 if (gateDotEl) {
215 if (s >= 3) {
216 gateDotEl.style.background = "#b45309";
217 gateDotEl.classList.add("im-dot-pulse");
218 } else {
219 gateDotEl.style.background = "#c4c6cf";
220 gateDotEl.classList.remove("im-dot-pulse");
221 }
222 }
223 if (gateStatusEl) {
224 gateStatusEl.textContent = s >= 3 ? "gates ready to re-run on accept" : "gates idle";
225 }
226
227 // show/hide green lines based on step
228 var codePane = document.getElementById("im-code-pane");
229 if (codePane) {
230 var greenLines = codePane.querySelectorAll("[data-is-green='true']");
231 greenLines.forEach(function(el, i) {
232 var lineDiv = el.parentElement;
233 var shown = s === 0 ? 1 : s === 1 ? 3 : 4;
234 if (i < shown) {
235 lineDiv.style.display = "";
236 } else {
237 lineDiv.style.display = "none";
238 }
239 // update cursor visibility
240 var agentLine = lineDiv.getAttribute("data-agent-line");
241 if (agentLine === "true") {
242 var caret = lineDiv.querySelector(".im-caret");
243 var caretLabel = lineDiv.querySelector(".im-caret-label");
244 if (s < 3) {
245 if (i === shown - 1) {
246 if (caret) caret.style.display = "inline-block";
247 if (caretLabel) caretLabel.style.display = "inline-block";
248 } else {
249 if (caret) caret.style.display = "none";
250 if (caretLabel) caretLabel.style.display = "none";
251 }
252 } else {
253 if (caret) caret.style.display = "none";
254 if (caretLabel) caretLabel.style.display = "none";
255 }
256 }
257 });
258 }
259 }
260
261 applyStep(step);
262
263 if (modeToggleEl) {
264 modeToggleEl.addEventListener("click", function() {
265 agentMode = agentMode === "suggest" ? "observe" : "suggest";
266 modeToggleEl.textContent = agentMode === "suggest" ? "switch to observe-only" : "switch to suggest";
267 });
268 }
269
270 if (msgSend && msgInput && chatFeed) {
271 function sendMsg() {
272 var val = msgInput.value.trim();
273 if (!val) return;
274 var msg = document.createElement("div");
275 msg.className = "im-chat-msg";
276 msg.innerHTML = '<span class="im-chat-avatar" style="background:var(--accent)">CC</span><p class="im-chat-text">' + val.replace(/</g,"&lt;").replace(/>/g,"&gt;") + '</p>';
277 chatFeed.appendChild(msg);
278 chatFeed.scrollTop = chatFeed.scrollHeight;
279 msgInput.value = "";
280 }
281 msgSend.addEventListener("click", sendMsg);
282 msgInput.addEventListener("keydown", function(e) {
283 if (e.key === "Enter") sendMsg();
284 });
285 }
286})();
287`;
288
289const css = `
290@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=Inter+Tight:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
291
292@keyframes imPulse {
293 0%, 100% { opacity: 1; }
294 50% { opacity: 0.35; }
295}
296@keyframes imCaret {
297 0%, 49% { opacity: 1; }
298 50%, 100% { opacity: 0; }
299}
300
301*, *::before, *::after { box-sizing: border-box; }
302
303.im-root {
304 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
305 font-size: 14px;
306 line-height: 1.55;
307 letter-spacing: -0.008em;
308 color: #16181d;
309 background: #fcfcfd;
310 min-height: 100vh;
311 display: flex;
312 flex-direction: column;
313 -webkit-font-smoothing: antialiased;
314 text-rendering: optimizeLegibility;
315}
316
317/* Header */
318.im-header {
319 height: 56px;
320 border-bottom: 1px solid rgba(22,24,29,0.07);
321 background: #fcfcfd;
322 display: flex;
323 align-items: center;
324 padding: 0 28px;
325 gap: 20px;
326}
327
328.im-logo {
329 display: inline-flex;
330 align-items: center;
331 gap: 9px;
332 font-family: 'Inter Tight', sans-serif;
333 font-weight: 600;
334 font-size: 15px;
335 letter-spacing: -0.02em;
336 color: #16181d;
337 text-decoration: none;
338}
339
340.im-logo-mark {
341 width: 16px;
342 height: 16px;
343 border-radius: 5px;
344 background: var(--accent);
345 display: inline-block;
346 flex-shrink: 0;
347}
348
349.im-header-sep {
350 color: #c4c6cf;
351}
352
353.im-header-sub {
354 font-size: 13px;
355 color: #16181d;
356 font-weight: 500;
357}
358
359.im-header-right {
360 margin-left: auto;
361 display: flex;
362 align-items: center;
363 gap: 14px;
364}
365
366.im-header-note {
367 font-size: 12.5px;
368 color: #6b7080;
369}
370
371/* Main */
372.im-main {
373 flex: 1;
374 max-width: 1240px;
375 width: 100%;
376 margin: 0 auto;
377 padding: 44px 32px 80px;
378 box-sizing: border-box;
379}
380
381/* Intro */
382.im-intro {
383 max-width: 680px;
384 margin-bottom: 32px;
385}
386
387.im-eyebrow {
388 font-family: 'JetBrains Mono', monospace;
389 font-size: 11px;
390 letter-spacing: 0.12em;
391 text-transform: uppercase;
392 color: #8a8d99;
393 margin-bottom: 10px;
394}
395
396.im-h1 {
397 font-family: 'Inter Tight', sans-serif;
398 font-size: 28px;
399 font-weight: 600;
400 letter-spacing: -0.025em;
401 line-height: 1.15;
402 margin: 0 0 8px;
403 color: #111318;
404}
405
406.im-lead {
407 font-size: 14px;
408 color: #6b7080;
409 margin: 0;
410 line-height: 1.6;
411}
412
413/* Editor window */
414.im-window {
415 border: 1px solid rgba(22,24,29,0.10);
416 border-radius: 14px;
417 background: #ffffff;
418 overflow: hidden;
419 box-shadow: 0 1px 2px rgba(22,24,29,0.03), 0 16px 44px -24px rgba(22,24,29,0.16);
420}
421
422/* Title bar */
423.im-titlebar {
424 display: flex;
425 align-items: center;
426 gap: 8px;
427 padding: 10px 16px;
428 border-bottom: 1px solid rgba(22,24,29,0.07);
429 background: #fcfcfd;
430}
431
432.im-dot {
433 width: 10px;
434 height: 10px;
435 border-radius: 50%;
436 flex-shrink: 0;
437}
438.im-dot-r { background: #ff5f57; }
439.im-dot-y { background: #febc2e; }
440.im-dot-g { background: #28c840; }
441
442.im-titlebar-filename {
443 margin-left: 10px;
444 font-family: 'JetBrains Mono', monospace;
445 font-size: 12px;
446 color: #6b7080;
447}
448
449.im-titlebar-chips {
450 margin-left: auto;
451 display: inline-flex;
452 gap: 6px;
453}
454
455.im-chip {
456 display: inline-flex;
457 align-items: center;
458 gap: 5px;
459 padding: 2px 10px 2px 4px;
460 border-radius: 9999px;
461 font-size: 11px;
462 font-weight: 600;
463}
464
465.im-chip-you {
466 background: color-mix(in srgb, var(--accent) 7%, transparent);
467 border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
468 color: var(--accent);
469}
470
471.im-chip-claude {
472 background: rgba(30,127,92,0.07);
473 border: 1px solid rgba(30,127,92,0.22);
474 color: #1e7f5c;
475}
476
477.im-chip-avatar {
478 width: 16px;
479 height: 16px;
480 border-radius: 50%;
481 display: inline-flex;
482 align-items: center;
483 justify-content: center;
484 font-size: 8px;
485 font-weight: 700;
486 color: #fff;
487 flex-shrink: 0;
488}
489
490.im-chip-avatar-you { background: var(--accent); }
491.im-chip-avatar-claude { background: #1e7f5c; }
492
493/* Body grid */
494.im-body {
495 display: grid;
496 grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr);
497}
498
499/* Code pane */
500.im-code-pane {
501 border-right: 1px solid rgba(22,24,29,0.07);
502 background: #0f1116;
503 font-family: 'JetBrains Mono', monospace;
504 font-size: 12.5px;
505 line-height: 1.85;
506 padding: 14px 0;
507 overflow-x: auto;
508}
509
510.im-code-line {
511 display: grid;
512 grid-template-columns: 44px minmax(0, 1fr);
513}
514
515.im-line-num {
516 text-align: right;
517 padding-right: 14px;
518 color: #c4c6cf;
519 user-select: none;
520 opacity: 0.4;
521}
522
523.im-line-text {
524 white-space: pre;
525 position: relative;
526 padding-right: 16px;
527}
528
529.im-caret {
530 display: inline-block;
531 width: 2px;
532 height: 15px;
533 background: #1e7f5c;
534 vertical-align: text-bottom;
535 animation: imCaret 1s step-end infinite;
536}
537
538.im-caret-label {
539 display: inline-block;
540 font-family: 'Inter', sans-serif;
541 font-size: 9px;
542 font-weight: 600;
543 color: #fff;
544 background: #1e7f5c;
545 border-radius: 3px;
546 padding: 0 5px;
547 position: relative;
548 top: -9px;
549 left: 4px;
550}
551
552/* Session rail */
553.im-rail {
554 display: flex;
555 flex-direction: column;
556 background: #fcfcfd;
557 min-height: 340px;
558}
559
560.im-rail-status {
561 padding: 14px 18px;
562 border-bottom: 1px solid rgba(22,24,29,0.06);
563}
564
565.im-rail-label {
566 font-family: 'JetBrains Mono', monospace;
567 font-size: 10.5px;
568 letter-spacing: 0.1em;
569 text-transform: uppercase;
570 color: #8a8d99;
571 font-weight: 500;
572 margin-bottom: 10px;
573}
574
575.im-status-rows {
576 display: flex;
577 flex-direction: column;
578 gap: 8px;
579 font-size: 12.5px;
580}
581
582.im-status-row {
583 display: flex;
584 align-items: center;
585 gap: 8px;
586}
587
588.im-dot-status {
589 width: 7px;
590 height: 7px;
591 border-radius: 50%;
592 flex-shrink: 0;
593}
594
595.im-dot-green { background: #1e7f5c; }
596.im-gate-dot { background: #b45309; }
597
598.im-dot-pulse {
599 animation: imPulse 1.4s ease-in-out infinite;
600}
601
602.im-status-text {
603 color: #16181d;
604}
605
606.im-status-secondary {
607 color: #6b7080;
608}
609
610/* Chat feed */
611.im-chat-feed {
612 flex: 1;
613 padding: 14px 18px;
614 display: flex;
615 flex-direction: column;
616 gap: 12px;
617 overflow-y: auto;
618 max-height: 260px;
619}
620
621.im-chat-msg {
622 display: flex;
623 gap: 9px;
624 align-items: flex-start;
625}
626
627.im-chat-avatar {
628 width: 20px;
629 height: 20px;
630 border-radius: 50%;
631 color: #fff;
632 display: inline-flex;
633 align-items: center;
634 justify-content: center;
635 font-size: 8px;
636 font-weight: 700;
637 flex-shrink: 0;
638 margin-top: 1px;
639}
640
641.im-chat-text {
642 font-size: 12.5px;
643 color: #16181d;
644 margin: 0;
645 line-height: 1.55;
646}
647
648/* Chat input */
649.im-chat-input-row {
650 padding: 12px 14px;
651 border-top: 1px solid rgba(22,24,29,0.06);
652 display: flex;
653 gap: 8px;
654}
655
656.im-chat-input {
657 flex: 1;
658 border: 1px solid rgba(22,24,29,0.10);
659 border-radius: 8px;
660 padding: 7px 12px;
661 outline: none;
662 font-family: inherit;
663 font-size: 12.5px;
664 color: #16181d;
665 background: #ffffff;
666 box-sizing: border-box;
667 min-width: 0;
668}
669
670.im-chat-input:focus {
671 border-color: color-mix(in srgb, var(--accent) 35%, transparent);
672 box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent);
673}
674
675.im-chat-send {
676 padding: 7px 13px;
677 border-radius: 8px;
678 font-size: 12px;
679 font-weight: 500;
680 border: 0;
681 background: #16181d;
682 color: #fff;
683 cursor: pointer;
684 font-family: inherit;
685 white-space: nowrap;
686 transition: opacity 0.15s;
687}
688
689.im-chat-send:hover {
690 opacity: 0.85;
691}
692
693/* Status bar */
694.im-statusbar {
695 display: flex;
696 align-items: center;
697 gap: 16px;
698 padding: 7px 16px;
699 border-top: 1px solid rgba(22,24,29,0.07);
700 background: #fcfcfd;
701 font-family: 'JetBrains Mono', monospace;
702 font-size: 11px;
703 color: #8a8d99;
704}
705
706.im-statusbar-right {
707 margin-left: auto;
708}
709
710.im-mode-toggle {
711 border: 0;
712 background: none;
713 color: var(--accent);
714 cursor: pointer;
715 font-family: 'JetBrains Mono', monospace;
716 font-size: 11px;
717 padding: 0;
718 text-decoration: underline;
719 text-decoration-color: transparent;
720 transition: text-decoration-color 0.15s;
721}
722
723.im-mode-toggle:hover {
724 text-decoration-color: var(--accent);
725}
726
727/* Footer note */
728.im-footer-note {
729 display: flex;
730 justify-content: space-between;
731 gap: 24px;
732 flex-wrap: wrap;
733 margin-top: 24px;
734 align-items: flex-start;
735}
736
737.im-footer-text {
738 font-size: 12.5px;
739 color: #8a8d99;
740 margin: 0;
741 max-width: 60ch;
742 line-height: 1.6;
743}
744
745.im-footer-strong {
746 color: #16181d;
747 font-weight: 500;
748}
749
750.im-footer-link {
751 font-size: 13px;
752 color: var(--accent);
753 text-decoration: none;
754 white-space: nowrap;
755}
756
757.im-footer-link:hover {
758 text-decoration: underline;
759}
760
761@media (max-width: 768px) {
762 .im-main {
763 padding: 28px 18px 60px;
764 }
765 .im-body {
766 grid-template-columns: 1fr;
767 }
768 .im-rail {
769 border-top: 1px solid rgba(22,24,29,0.07);
770 border-right: none;
771 }
772}
773`;
Deletedsrc/views/landing-2030.tsx+0−511View fileUnifiedSplit
1/**
2 * Landing2030 — the public marketing landing page.
3 *
4 * A fully self-contained light HTML document (its own <head>, fonts, and CSS)
5 * rendered directly from the root route, bypassing the dark app Layout so the
6 * marketing surface can be a pristine, Linear/Stripe-grade design without
7 * fighting the application chrome. Theme: white, editorial, calm — the page
8 * should read as trustworthy infrastructure, not sci-fi. Entrance motion is
9 * CSS-only, brief, and degrades to fully-visible content when JS or
10 * animation is unavailable.
11 */
12import type { FC } from "hono/jsx";
13
14export interface Landing2030Props {
15 // Reserved for future use. The stat band intentionally shows
16 // capability metrics rather than live counts so the page reads strong
17 // at any scale.
18 stats?: { publicRepos?: number; users?: number };
19}
20
21/* ---- small stroke icons (inherit currentColor) ---------------------- */
22const IconReview: FC = () => (
23 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
24 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
25 <path d="M12 3l2.2 4.6L19 8.3l-3.4 3.4.8 4.8L12 14.2 7.6 16.5l.8-4.8L5 8.3l4.8-.7z" />
26 </svg>
27);
28const IconMerge: FC = () => (
29 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
30 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
31 <circle cx="6" cy="6" r="2.4" /><circle cx="6" cy="18" r="2.4" />
32 <circle cx="18" cy="9" r="2.4" /><path d="M6 8.4v7.2M8.3 7.2C13 7.6 15.6 9 15.6 9" />
33 </svg>
34);
35const IconGate: FC = () => (
36 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
37 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
38 <path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6z" /><path d="M9 12l2 2 4-4" />
39 </svg>
40);
41const IconGit: FC = () => (
42 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
43 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
44 <circle cx="6" cy="6" r="2.4" /><circle cx="6" cy="18" r="2.4" />
45 <circle cx="18" cy="14" r="2.4" /><path d="M6 8.4v7.2M6 14a8 8 0 008-8h1.8" />
46 </svg>
47);
48const IconCI: FC = () => (
49 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
50 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
51 <path d="M12 3a9 9 0 109 9" /><path d="M12 7v5l3 2" /><path d="M21 3v4h-4" />
52 </svg>
53);
54const IconIntel: FC = () => (
55 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
56 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
57 <path d="M12 3a4 4 0 014 4c1.6.6 2.6 2 2.6 3.8 0 1-.4 2-1 2.7.3.5.4 1.1.4 1.7A3.8 3.8 0 0112 19a3.8 3.8 0 01-6-3.8c0-.6.1-1.2.4-1.7-.6-.7-1-1.7-1-2.7C5.4 9 6.4 7.6 8 7a4 4 0 014-4z" />
58 <path d="M12 3v16" />
59 </svg>
60);
61
62const FEATURES: { icon: FC; title: string; body: string }[] = [
63 { icon: IconReview, title: "Claude code review",
64 body: "Every pull request gets a senior-level review the moment it opens — line-level comments, risk flags, and a verdict, in seconds." },
65 { icon: IconMerge, title: "Auto-merge the instant gates pass",
66 body: "Gates green and review clean? Gluecron merges autonomously. Label an issue, get a shipped PR — no waiting." },
67 { icon: IconGate, title: "Push-time gate enforcement",
68 body: "Security and quality gates run at the moment of push — not minutes later in CI. Failing changes are stopped before they reach your branch." },
69 { icon: IconGit, title: "Git-native hosting",
70 body: "Full Smart-HTTP git over the wire. Clone, push, fork, and browse — everything you expect from a host, self-owned." },
71 { icon: IconCI, title: "CI that comes built-in",
72 body: "No YAML archaeology. Checks, deploys, and post-receive automation are part of the platform, wired from first push." },
73 { icon: IconIntel, title: "Semantic code intelligence",
74 body: "A vector-indexed understanding of your whole repo powers search, review, and the agents that act on your behalf." },
75];
76
77const STEPS: { n: string; title: string; body: string }[] = [
78 { n: "01", title: "Label an issue", body: "Drop a label on an issue — or just describe what you want. That's the whole input." },
79 { n: "02", title: "Agents go to work", body: "Claude opens a branch, writes the change, and submits a pull request against your gates." },
80 { n: "03", title: "Reviewed & gated", body: "The PR is reviewed line-by-line and run through push-time security and quality gates." },
81 { n: "04", title: "Merged automatically", body: "Green across the board? The PR merges and deploys, with every action attributed and recorded in your history." },
82];
83
84export const Landing2030Page: FC<Landing2030Props> = () => {
85 const title = "Gluecron — The AI-native git host";
86 const desc =
87 "The AI-native git host. Spec to PR in 90 seconds. Automated Claude review, push-time gates, and auto-merge — on infrastructure you can self-host.";
88 return (
89 <html lang="en">
90 <head>
91 <meta charset="UTF-8" />
92 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
93 <meta name="theme-color" content="#ffffff" />
94 <title>{title}</title>
95 <meta name="description" content={desc} />
96 <meta property="og:title" content={title} />
97 <meta property="og:description" content={desc} />
98 <meta property="og:type" content="website" />
99 <meta name="twitter:card" content="summary_large_image" />
100 <link rel="icon" type="image/svg+xml" href="/icon.svg" />
101 <link rel="preconnect" href="https://fonts.googleapis.com" />
102 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
103 <link
104 rel="stylesheet"
105 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
106 />
107 <style dangerouslySetInnerHTML={{ __html: landing2030Css }} />
108 </head>
109 <body>
110 {/* ---- nav ---- */}
111 <header class="nv" id="nv">
112 <div class="nv-in">
113 <a href="/" class="nv-logo">
114 <span class="nv-mark" aria-hidden="true" />
115 gluecron
116 </a>
117 <nav class="nv-links" aria-label="Primary">
118 <a href="#features">Product</a>
119 <a href="#loop">How it works</a>
120 <a href="/pricing">Pricing</a>
121 <a href="/explore">Explore</a>
122 </nav>
123 <div class="nv-cta">
124 <a href="/login" class="btn btn-ghost">Sign in</a>
125 <a href="/register" class="btn btn-solid">Start building</a>
126 </div>
127 </div>
128 </header>
129
130 {/* ---- hero ---- */}
131 <section class="hero">
132 <div class="hero-glow" aria-hidden="true" />
133 <div class="wrap hero-in">
134 <a href="#loop" class="eyebrow rise" style="--d:0ms">
135 <span class="eyebrow-dot" /> The AI-native git host
136 </a>
137 <h1 class="display rise" style="--d:60ms">
138 The git host built for <span class="grad">AI-assisted teams</span>.
139 </h1>
140 <p class="lede rise" style="--d:120ms">
141 Gluecron hosts your code, reviews every pull request with Claude,
142 enforces gates at push time, and merges clean work once every
143 gate passes. Spec to PR in 90 seconds, with a review trail you
144 can audit.
145 </p>
146 <div class="hero-actions rise" style="--d:180ms">
147 <a href="/register" class="btn btn-solid btn-lg">Start building →</a>
148 <a href="#loop" class="btn btn-ghost btn-lg">See how it works</a>
149 </div>
150 <div class="hero-trust rise" style="--d:240ms">
151 Self-hosted · Git-native · Claude-first
152 </div>
153
154 {/* product card mock */}
155 <div class="hero-card rise" style="--d:320ms" aria-hidden="true">
156 <div class="hc-bar">
157 <span class="hc-dot" /><span class="hc-dot" /><span class="hc-dot" />
158 <span class="hc-path">gluecron.com / your-org / api · #128</span>
159 </div>
160 <div class="hc-body">
161 <div class="hc-pr">
162 <span class="hc-badge hc-merged">✓ Merged</span>
163 <span class="hc-prtitle">Fix race condition in token refresh</span>
164 </div>
165 <div class="hc-review">
166 <span class="hc-ava">C</span>
167 <div class="hc-rev-body">
168 <div class="hc-rev-head">Claude review · <em>approved</em></div>
169 <div class="hc-rev-text">
170 Mutex now guards the refresh path; the double-fetch under
171 contention is resolved. Gates green. Auto-merging.
172 </div>
173 </div>
174 </div>
175 <div class="hc-checks">
176 <span class="hc-check ok">● gate: security</span>
177 <span class="hc-check ok">● gate: tests</span>
178 <span class="hc-check ok">● review: Claude</span>
179 <span class="hc-check ok">● deploy: live</span>
180 </div>
181 </div>
182 </div>
183 </div>
184 </section>
185
186 {/* ---- stat band ---- */}
187 <section class="stats">
188 <div class="wrap stats-in">
189 <div class="stat"><div class="stat-n">&lt; 30s</div><div class="stat-l">to first AI review</div></div>
190 <div class="stat"><div class="stat-n">24/7</div><div class="stat-l">autonomous merges</div></div>
191 <div class="stat"><div class="stat-n">100%</div><div class="stat-l">push-time gate coverage</div></div>
192 <div class="stat"><div class="stat-n">Self-owned</div><div class="stat-l">your code, your server</div></div>
193 </div>
194 </section>
195
196 {/* ---- features ---- */}
197 <section class="sec" id="features">
198 <div class="wrap">
199 <div class="sec-head">
200 <span class="kicker">The platform</span>
201 <h2 class="h2">Everything you expect from a git host. Plus the parts you automate.</h2>
202 <p class="sub">A complete git host with code intelligence wired into every step — review, gates, CI, and automated merge, native to the platform.</p>
203 </div>
204 <div class="grid">
205 {FEATURES.map((f) => {
206 const Ic = f.icon;
207 return (
208 <div class="card">
209 <div class="card-ic"><Ic /></div>
210 <h3 class="card-t">{f.title}</h3>
211 <p class="card-b">{f.body}</p>
212 </div>
213 );
214 })}
215 </div>
216 </div>
217 </section>
218
219 {/* ---- the loop ---- */}
220 <section class="sec sec-alt" id="loop">
221 <div class="wrap">
222 <div class="sec-head">
223 <span class="kicker">The closed loop</span>
224 <h2 class="h2">From a label to a merged PR — every step reviewed and logged.</h2>
225 <p class="sub">Gluecron closes the loop between intent and production. You set direction; the platform does the round-trip, with a full audit trail.</p>
226 </div>
227 <div class="loop">
228 {STEPS.map((s, i) => (
229 <div class="step">
230 <div class="step-n">{s.n}</div>
231 <h3 class="step-t">{s.title}</h3>
232 <p class="step-b">{s.body}</p>
233 {i < STEPS.length - 1 && <span class="step-arrow" aria-hidden="true"></span>}
234 </div>
235 ))}
236 </div>
237 </div>
238 </section>
239
240 {/* ---- why-Gluecron band ---- */}
241 <section class="vision">
242 <div class="vision-grid" aria-hidden="true" />
243 <div class="wrap vision-in">
244 <span class="kicker kicker-light">Why Gluecron</span>
245 <h2 class="vh">Review, gates, and merges —<br />automated, with you in control.</h2>
246 <p class="vsub">
247 Gluecron automates the repetitive parts of shipping — review,
248 gating, merging, deploying — while keeping every action visible,
249 attributable, and reversible. We didn't bolt AI onto a git host.
250 We built the git host around it.
251 </p>
252 <div class="vstats">
253 <div class="vstat"><b>Automated</b><span>review → gate → merge → deploy</span></div>
254 <div class="vstat"><b>Always on</b><span>works while you're away</span></div>
255 <div class="vstat"><b>Self-owned</b><span>your code, your server, your keys</span></div>
256 </div>
257 </div>
258 </section>
259
260 {/* ---- differentiator ---- */}
261 <section class="sec">
262 <div class="wrap quote-wrap">
263 <p class="quote">
264 “GitHub gives you a place to <em>store</em> code.
265 Gluecron is where code gets <em>reviewed, gated, and merged.</em>
266 </p>
267 </div>
268 </section>
269
270 {/* ---- final CTA ---- */}
271 <section class="cta">
272 <div class="wrap cta-in">
273 <h2 class="cta-h">Start building on Gluecron.</h2>
274 <p class="cta-sub">Spin up a repository, push a commit, and watch the loop close.</p>
275 <div class="hero-actions">
276 <a href="/register" class="btn btn-solid btn-lg">Create your account →</a>
277 <a href="/explore" class="btn btn-ghost btn-lg">Explore public repos</a>
278 </div>
279 </div>
280 </section>
281
282 {/* ---- footer ---- */}
283 <footer class="ft">
284 <div class="wrap ft-in">
285 <div class="ft-brand">
286 <a href="/" class="nv-logo"><span class="nv-mark" aria-hidden="true" />gluecron</a>
287 <p class="ft-tag">The AI-native git host. Self-hosted, auditable, git-native.</p>
288 </div>
289 <div class="ft-cols">
290 <div class="ft-col">
291 <h4>Product</h4>
292 <a href="#features">Features</a>
293 <a href="/pricing">Pricing</a>
294 <a href="/explore">Explore</a>
295 </div>
296 <div class="ft-col">
297 <h4>Company</h4>
298 <a href="/about">About</a>
299 <a href="/login">Sign in</a>
300 <a href="/register">Start building</a>
301 </div>
302 <div class="ft-col">
303 <h4>Account</h4>
304 <a href="/login">Log in</a>
305 <a href="/register">Register</a>
306 <a href="/settings">Settings</a>
307 </div>
308 </div>
309 </div>
310 <div class="wrap ft-bottom">
311 <span>© {new Date().getFullYear()} Gluecron</span>
312 <span>Self-hosted · Git-native · Claude-first</span>
313 </div>
314 </footer>
315
316 <script dangerouslySetInnerHTML={{ __html: landing2030Js }} />
317 </body>
318 </html>
319 );
320};
321
322export default Landing2030Page;
323
324const landing2030Js = `
325(function(){
326 var nv = document.getElementById('nv');
327 function onScroll(){ if(!nv) return; nv.classList.toggle('nv-stuck', window.scrollY > 8); }
328 window.addEventListener('scroll', onScroll, {passive:true}); onScroll();
329 // additive scroll-reveal: base state is already visible, this only enhances
330 if ('IntersectionObserver' in window && !window.matchMedia('(prefers-reduced-motion: reduce)').matches){
331 var io = new IntersectionObserver(function(es){
332 es.forEach(function(e){ if(e.isIntersecting){ e.target.classList.add('seen'); io.unobserve(e.target); } });
333 }, {threshold:0.12});
334 document.querySelectorAll('.card,.step,.stat,.vstat').forEach(function(el){ el.classList.add('reveal'); io.observe(el); });
335 }
336})();
337`;
338
339const landing2030Css = `
340:root{
341 --bg:#ffffff; --bg-soft:#fafafb; --ink:#0a0b0d; --ink-2:#3a3d45;
342 --muted:#676d78; --line:rgba(13,16,23,.08); --line-2:rgba(13,16,23,.12);
343 --brand:var(--accent); --brand-2:var(--accent); --brand-3:#3a49b4;
344 --grad:linear-gradient(100deg,var(--accent) 0%,var(--accent) 100%);
345 --radius:16px; --shadow:0 1px 2px rgba(13,16,23,.04),0 12px 32px rgba(13,16,23,.06);
346 --maxw:1140px;
347}
348*{box-sizing:border-box}
349html{scroll-behavior:smooth}
350body{margin:0;background:var(--bg);color:var(--ink);
351 font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
352 font-size:17px;line-height:1.6;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
353a{color:inherit;text-decoration:none}
354.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
355.display,.h2,.vh,.cta-h{font-family:'Inter Tight','Inter',sans-serif;letter-spacing:-.02em;font-weight:700}
356.grad{background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent}
357
358/* nav */
359.nv{position:sticky;top:0;z-index:50;backdrop-filter:saturate(180%) blur(12px);
360 background:rgba(255,255,255,.72);border-bottom:1px solid transparent;transition:border-color .2s,box-shadow .2s,background .2s}
361.nv-stuck{border-bottom-color:var(--line);box-shadow:0 1px 0 rgba(13,16,23,.02)}
362.nv-in{max-width:var(--maxw);margin:0 auto;padding:14px 24px;display:flex;align-items:center;gap:24px}
363.nv-logo{display:inline-flex;align-items:center;gap:9px;font-family:'Inter Tight',sans-serif;font-weight:700;font-size:19px;letter-spacing:-.02em}
364.nv-mark{width:18px;height:18px;border-radius:6px;background:var(--grad);box-shadow:0 1px 3px rgba(13,16,23,.18);display:inline-block}
365.nv-links{display:flex;gap:26px;margin-left:14px}
366.nv-links a{color:var(--ink-2);font-size:15px;font-weight:500;transition:color .15s}
367.nv-links a:hover{color:var(--ink)}
368.nv-cta{margin-left:auto;display:flex;align-items:center;gap:10px}
369.btn{display:inline-flex;align-items:center;gap:6px;border-radius:10px;font-weight:600;font-size:15px;
370 padding:9px 16px;cursor:pointer;transition:transform .12s,box-shadow .2s,background .2s,border-color .2s;border:1px solid transparent;white-space:nowrap}
371.btn:hover{transform:translateY(-1px)}
372.btn-solid{background:var(--ink);color:#fff}
373.btn-solid:hover{box-shadow:0 8px 22px rgba(13,16,23,.18)}
374.btn-ghost{color:var(--ink);border-color:var(--line-2);background:rgba(255,255,255,.6)}
375.btn-ghost:hover{border-color:var(--ink);background:#fff}
376.btn-lg{padding:13px 22px;font-size:16px;border-radius:12px}
377
378/* hero */
379.hero{position:relative;overflow:hidden;padding:84px 0 40px;text-align:center}
380.hero-glow{position:absolute;inset:-20% 0 auto 0;height:620px;z-index:0;pointer-events:none;
381 background:radial-gradient(60% 60% at 50% 0%,color-mix(in srgb, var(--accent) 7%, transparent),transparent 70%)}
382.hero-in{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center}
383.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;font-weight:600;color:var(--ink-2);
384 background:#fff;border:1px solid var(--line-2);border-radius:999px;padding:7px 14px;box-shadow:var(--shadow)}
385.eyebrow-dot{width:7px;height:7px;border-radius:50%;background:var(--grad)}
386.display{font-size:clamp(40px,7vw,76px);line-height:1.02;margin:26px 0 0;max-width:14ch}
387.lede{font-size:clamp(17px,2.2vw,21px);color:var(--muted);max-width:60ch;margin:22px auto 0;font-weight:450}
388.hero-actions{display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin-top:30px}
389.hero-trust{margin-top:18px;font-size:13px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
390
391/* hero card */
392.hero-card{margin:54px auto 0;max-width:760px;width:100%;text-align:left;background:#fff;
393 border:1px solid var(--line);border-radius:18px;box-shadow:0 30px 70px -30px rgba(13,16,23,.28),var(--shadow);overflow:hidden}
394.hc-bar{display:flex;align-items:center;gap:7px;padding:12px 16px;border-bottom:1px solid var(--line);background:var(--bg-soft)}
395.hc-dot{width:11px;height:11px;border-radius:50%;background:#dfe1e6}
396.hc-path{margin-left:10px;font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--muted)}
397.hc-body{padding:20px}
398.hc-pr{display:flex;align-items:center;gap:12px}
399.hc-badge{font-size:12.5px;font-weight:700;padding:4px 10px;border-radius:999px}
400.hc-merged{background:color-mix(in srgb, var(--accent) 8%, transparent);color:var(--brand)}
401.hc-prtitle{font-weight:600;font-size:15.5px}
402.hc-review{display:flex;gap:12px;margin-top:18px;padding:14px;border:1px solid var(--line);border-radius:12px;background:var(--bg-soft)}
403.hc-ava{flex:none;width:30px;height:30px;border-radius:8px;background:var(--grad);color:#fff;font-weight:700;
404 display:grid;place-items:center;font-size:14px}
405.hc-rev-head{font-size:13.5px;font-weight:600;color:var(--ink-2)}
406.hc-rev-head em{color:var(--brand);font-style:normal}
407.hc-rev-text{font-size:14px;color:var(--muted);margin-top:3px}
408.hc-checks{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
409.hc-check{font-family:'JetBrains Mono',monospace;font-size:12px;padding:5px 10px;border-radius:8px;border:1px solid var(--line);color:var(--ink-2)}
410.hc-check.ok{color:#2c8a52}.hc-check.ok::first-letter{color:#2c8a52}
411
412/* stats */
413.stats{border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:var(--bg-soft)}
414.stats-in{display:grid;grid-template-columns:repeat(4,1fr);gap:24px;padding:40px 24px}
415.stat{text-align:center}
416.stat-n{font-family:'Inter Tight',sans-serif;font-weight:800;font-size:clamp(28px,4vw,40px);letter-spacing:-.02em}
417.stat-l{font-size:13.5px;color:var(--muted);margin-top:4px}
418
419/* sections */
420.sec{padding:92px 0}
421.sec-alt{background:var(--bg-soft);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
422.sec-head{max-width:680px;margin:0 auto 52px;text-align:center}
423.kicker{display:inline-block;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
424 color:var(--brand);margin-bottom:14px}
425.h2{font-size:clamp(28px,4.4vw,46px);line-height:1.08;margin:0}
426.sub{color:var(--muted);font-size:18px;margin:16px auto 0;max-width:56ch}
427
428/* feature grid */
429.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
430.card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:26px;transition:transform .18s,box-shadow .25s,border-color .2s}
431.card:hover{transform:translateY(-3px);box-shadow:var(--shadow);border-color:var(--line-2)}
432.card-ic{width:42px;height:42px;border-radius:11px;display:grid;place-items:center;color:var(--brand);
433 background:color-mix(in srgb, var(--accent) 7%, transparent);border:1px solid color-mix(in srgb, var(--accent) 14%, transparent);margin-bottom:16px}
434.card-ic svg{width:22px;height:22px}
435.card-t{font-size:18px;font-weight:600;margin:0 0 7px;font-family:'Inter Tight',sans-serif;letter-spacing:-.01em}
436.card-b{color:var(--muted);font-size:15px;margin:0;line-height:1.6}
437
438/* loop */
439.loop{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
440.step{position:relative;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:24px}
441.step-n{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:600;color:var(--brand);margin-bottom:12px}
442.step-t{font-size:17px;font-weight:600;margin:0 0 6px;font-family:'Inter Tight',sans-serif}
443.step-b{color:var(--muted);font-size:14.5px;margin:0}
444.step-arrow{position:absolute;right:-13px;top:50%;transform:translateY(-50%);color:var(--line-2);font-size:20px;z-index:2}
445
446/* vision band */
447.vision{position:relative;overflow:hidden;padding:104px 0;color:#fff;text-align:center;
448 background:radial-gradient(120% 120% at 50% -10%,#1d2335 0%,#11141f 45%,#0a0c12 100%)}
449.vision-grid{position:absolute;inset:0;opacity:.5;
450 background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px);
451 background-size:54px 54px;mask-image:radial-gradient(80% 80% at 50% 0%,#000,transparent 75%)}
452.vision-in{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center}
453.kicker-light{color:#a9b4ee}
454.vh{font-size:clamp(30px,5vw,54px);line-height:1.08;margin:0;letter-spacing:-.02em}
455.vsub{color:rgba(255,255,255,.74);font-size:18px;max-width:62ch;margin:22px auto 0}
456.vstats{display:flex;gap:40px;flex-wrap:wrap;justify-content:center;margin-top:42px}
457.vstat{display:flex;flex-direction:column;gap:3px}
458.vstat b{font-family:'Inter Tight',sans-serif;font-size:19px}
459.vstat span{color:rgba(255,255,255,.6);font-size:13.5px}
460
461/* quote */
462.quote-wrap{max-width:1200px;margin:0 auto;text-align:center}
463.quote{font-family:'Inter Tight',sans-serif;font-weight:600;font-size:clamp(24px,3.6vw,38px);
464 line-height:1.25;letter-spacing:-.02em;margin:0}
465.quote em{font-style:normal;background:var(--grad);-webkit-background-clip:text;background-clip:text;color:transparent}
466
467/* cta */
468.cta{padding:96px 0}
469.cta-in{max-width:720px;margin:0 auto;text-align:center;background:#fff;border:1px solid var(--line);
470 border-radius:24px;padding:56px 32px;box-shadow:var(--shadow);position:relative;overflow:hidden}
471.cta-in::before{content:"";position:absolute;inset:-40% 0 auto 0;height:260px;
472 background:radial-gradient(50% 60% at 50% 0%,color-mix(in srgb, var(--accent) 7%, transparent),transparent 70%);pointer-events:none}
473.cta-h{font-size:clamp(28px,4.4vw,44px);margin:0;position:relative}
474.cta-sub{color:var(--muted);font-size:18px;margin:14px 0 28px;position:relative}
475.cta .hero-actions{position:relative}
476
477/* footer */
478.ft{border-top:1px solid var(--line);background:var(--bg-soft);padding:56px 0 28px}
479.ft-in{display:flex;gap:40px;flex-wrap:wrap;justify-content:space-between}
480.ft-tag{color:var(--muted);font-size:14px;margin:12px 0 0;max-width:30ch}
481.ft-cols{display:flex;gap:64px;flex-wrap:wrap}
482.ft-col{display:flex;flex-direction:column;gap:10px}
483.ft-col h4{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 4px;font-weight:700}
484.ft-col a{color:var(--ink-2);font-size:14.5px;transition:color .15s}
485.ft-col a:hover{color:var(--ink)}
486.ft-bottom{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;
487 margin-top:40px;padding-top:22px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}
488
489/* entrance + reveal */
490@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
491.rise{animation:rise .4s cubic-bezier(.22,.61,.36,1) backwards;animation-delay:var(--d,0ms)}
492.reveal{opacity:0;transform:translateY(8px);transition:opacity .35s ease,transform .35s cubic-bezier(.22,.61,.36,1)}
493.reveal.seen{opacity:1;transform:none}
494
495/* responsive */
496@media(max-width:900px){
497 .grid,.loop{grid-template-columns:repeat(2,1fr)}
498 .stats-in{grid-template-columns:repeat(2,1fr);gap:28px 16px}
499 .step-arrow{display:none}
500 .nv-links{display:none}
501}
502@media(max-width:560px){
503 .grid,.loop{grid-template-columns:1fr}
504 .hero{padding:56px 0 24px}
505 .ft-cols{gap:36px}
506}
507@media(prefers-reduced-motion:reduce){
508 .rise,.reveal{animation:none!important;opacity:1!important;transform:none!important}
509 html{scroll-behavior:auto}
510}
511`;
Modifiedsrc/views/landing-pro.tsx+6−0View fileUnifiedSplit
223223 <a href="/import" class="lp-btn lp-btn-outline lp-btn-lg">Migrate from GitHub</a>
224224 </div>
225225 <div class="lp-hero-links">
226 {/* The playground funnel (/play) lived only on the legacy
227 landing and vanished when this page took over the home
228 route — a working zero-signup entry point with no door
229 to it. Restored 2026-08-27. */}
230 <a href="/play">Try it without signing up</a>
231 <span aria-hidden="true">·</span>
226232 <a href="/demo">Try the live demo</a>
227233 <span aria-hidden="true">·</span>
228234 <a href="/vs-github">Compare to GitHub</a>
Deletedsrc/views/landing.tsx+0−3703View fileUnifiedSplit
Large file (3,703 lines). Load full file
Deletedsrc/views/onboarding-v2.tsx+0−750View fileUnifiedSplit
1import type { FC } from "hono/jsx";
2
3export interface OnboardingV2Props {
4 situation?: string;
5 user?: { username: string };
6}
7
8export const OnboardingV2: FC<OnboardingV2Props> = (props) => {
9 const initSit = props.situation ?? "broken-codebase";
10
11 return (
12 <>
13 <style dangerouslySetInnerHTML={{ __html: css }} />
14 <div class="ob-root" {...{ "data-init-situation": initSit }}>
15 <header class="ob-header">
16 <a href="/" class="ob-logo">
17 <span class="ob-logo-mark"></span>gluecron
18 </a>
19 <span class="ob-sep">/</span>
20 <span class="ob-page-label">Welcome</span>
21 <span class="ob-tagline">First five minutes — the wizard adapts to who's arriving</span>
22 </header>
23
24 <main class="ob-main">
25 <div class="ob-intro">
26 <div class="ob-eyebrow">Adaptive onboarding · one flow, four shapes</div>
27 <h1 class="ob-h1">Onboarding that reads the situation.</h1>
28 <p class="ob-lead">
29 The same signup senses who's arriving — a human with a broken repo, an agent teammate,
30 a locked-down enterprise, or someone just browsing — and reshapes itself. Pick a situation
31 to see the wizard adapt.
32 </p>
33 </div>
34
35 <div class="ob-pills" id="ob-pills"></div>
36
37 <div class="ob-grid">
38 <section class="ob-wizard">
39 <div class="ob-wiz-head" id="ob-wiz-head"></div>
40 <div class="ob-wiz-steps" id="ob-wiz-steps"></div>
41 <div class="ob-wiz-footer" id="ob-wiz-footer"></div>
42 </section>
43
44 <aside class="ob-rail">
45 <div>
46 <h3 class="ob-rail-hed">What the system sensed</h3>
47 <div class="ob-sensed-card" id="ob-sensed-card"></div>
48 </div>
49 <div>
50 <h3 class="ob-rail-hed">The three invariants</h3>
51 <div class="ob-invs">
52 <div class="ob-inv">
53 <div class="ob-inv-title">Identity coherence</div>
54 <p class="ob-inv-body">
55 Playground → verified profile keeps your full sandbox history. Nothing is lost on upgrade.
56 </p>
57 </div>
58 <div class="ob-inv">
59 <div class="ob-inv-title">Telemetry handshake</div>
60 <p class="ob-inv-body">
61 Setup parameters log to flywheel telemetry from the first second — onboarding health is
62 measured, not assumed.
63 </p>
64 </div>
65 <div class="ob-inv">
66 <div class="ob-inv-title">Local sync rails</div>
67 <p class="ob-inv-body">
68 The setup wizard connects your machine straight to the sovereign array — no external edge
69 network in the path.
70 </p>
71 </div>
72 </div>
73 </div>
74 </aside>
75 </div>
76 </main>
77 </div>
78 <script dangerouslySetInnerHTML={{ __html: js }} />
79 </>
80 );
81};
82
83export default OnboardingV2;
84
85/* ─── styles ─────────────────────────────────────────────────────────────── */
86
87const css = `
88@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=Inter+Tight:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
89
90@keyframes gcPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
91
92*, *::before, *::after { box-sizing: border-box; }
93
94.ob-root {
95 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
96 font-size: 14px;
97 line-height: 1.55;
98 letter-spacing: -0.008em;
99 color: #16181d;
100 background: #fcfcfd;
101 min-height: 100vh;
102 display: flex;
103 flex-direction: column;
104 -webkit-font-smoothing: antialiased;
105 text-rendering: optimizeLegibility;
106}
107
108/* ── header ─────────────────────────────────────────────────────────────── */
109.ob-header {
110 height: 56px;
111 border-bottom: 1px solid rgba(22,24,29,0.07);
112 background: #fcfcfd;
113 display: flex;
114 align-items: center;
115 padding: 0 28px;
116 gap: 20px;
117 position: sticky;
118 top: 0;
119 z-index: 50;
120}
121.ob-logo {
122 display: inline-flex;
123 align-items: center;
124 gap: 9px;
125 font-family: 'Inter Tight', sans-serif;
126 font-weight: 600;
127 font-size: 15px;
128 letter-spacing: -0.02em;
129 color: #16181d;
130 text-decoration: none;
131}
132.ob-logo-mark {
133 width: 16px;
134 height: 16px;
135 border-radius: 5px;
136 background: var(--accent);
137 display: inline-block;
138 flex-shrink: 0;
139}
140.ob-sep { color: #c4c6cf; }
141.ob-page-label { font-size: 13px; color: #16181d; font-weight: 500; }
142.ob-tagline { margin-left: auto; font-size: 12.5px; color: #6b7080; }
143
144/* ── main layout ─────────────────────────────────────────────────────────── */
145.ob-main {
146 flex: 1;
147 max-width: 1180px;
148 width: 100%;
149 margin: 0 auto;
150 padding: 44px 32px 88px;
151}
152
153/* ── intro ───────────────────────────────────────────────────────────────── */
154.ob-intro { max-width: 680px; margin-bottom: 32px; }
155.ob-eyebrow {
156 font-family: 'JetBrains Mono', monospace;
157 font-size: 11px;
158 letter-spacing: 0.12em;
159 text-transform: uppercase;
160 color: #8a8d99;
161 margin-bottom: 10px;
162}
163.ob-h1 {
164 font-family: 'Inter Tight', sans-serif;
165 font-size: 28px;
166 font-weight: 600;
167 letter-spacing: -0.025em;
168 line-height: 1.15;
169 margin: 0 0 8px;
170 color: #111318;
171}
172.ob-lead { font-size: 14px; color: #6b7080; margin: 0; line-height: 1.6; }
173
174/* ── situation pills ─────────────────────────────────────────────────────── */
175.ob-pills { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 28px; }
176.ob-pill {
177 padding: 8px 16px;
178 border-radius: 9999px;
179 font-size: 13px;
180 font-weight: 500;
181 border: 1px solid rgba(22,24,29,0.12);
182 background: #ffffff;
183 color: #6b7080;
184 cursor: pointer;
185 font-family: inherit;
186 white-space: nowrap;
187 transition: border-color 0.12s;
188}
189.ob-pill:hover { border-color: rgba(22,24,29,0.30); }
190.ob-pill--active {
191 background: #16181d;
192 color: #ffffff;
193 border-color: #16181d;
194 font-weight: 600;
195}
196
197/* ── two-col grid ────────────────────────────────────────────────────────── */
198.ob-grid {
199 display: grid;
200 grid-template-columns: minmax(0, 1.5fr) minmax(300px, 1fr);
201 gap: 44px;
202 align-items: start;
203}
204
205/* ── wizard card ─────────────────────────────────────────────────────────── */
206.ob-wizard {
207 border: 1px solid rgba(22,24,29,0.09);
208 border-radius: 16px;
209 background: #ffffff;
210 overflow: hidden;
211}
212
213.ob-wiz-head {
214 padding: 20px 26px;
215 border-bottom: 1px solid rgba(22,24,29,0.06);
216 background: #fcfcfd;
217}
218.ob-wiz-head-row { display: flex; align-items: center; gap: 10px; }
219.ob-wiz-dot {
220 width: 8px;
221 height: 8px;
222 border-radius: 50%;
223 flex-shrink: 0;
224 animation: gcPulse 1.4s ease-in-out infinite;
225}
226.ob-wiz-title { font-size: 14.5px; font-weight: 600; color: #16181d; }
227.ob-wiz-body { font-size: 13px; color: #6b7080; margin: 6px 0 0; line-height: 1.6; }
228
229/* ── step timeline ───────────────────────────────────────────────────────── */
230.ob-wiz-steps { padding: 22px 26px; display: flex; flex-direction: column; gap: 0; }
231.ob-step {
232 display: grid;
233 grid-template-columns: 20px minmax(0, 1fr);
234 gap: 16px;
235 position: relative;
236}
237.ob-step:not(:last-child) { padding-bottom: 24px; }
238.ob-step-track { display: flex; flex-direction: column; align-items: center; }
239.ob-step-dot {
240 width: 8px;
241 height: 8px;
242 border-radius: 50%;
243 margin-top: 6px;
244 flex-shrink: 0;
245}
246.ob-step-line {
247 width: 1px;
248 flex: 1;
249 background: rgba(22,24,29,0.08);
250 margin-top: 6px;
251}
252.ob-step-title { font-size: 13.5px; font-weight: 600; color: #16181d; }
253.ob-step-body {
254 font-size: 13px;
255 color: #6b7080;
256 margin: 3px 0 0;
257 line-height: 1.6;
258 max-width: 56ch;
259}
260
261/* ── terminal snippet ────────────────────────────────────────────────────── */
262.ob-term {
263 margin: 12px 0 0;
264 border: 1px solid rgba(22,24,29,0.9);
265 border-radius: 10px;
266 background: #0f1116;
267 overflow: hidden;
268}
269.ob-term-bar {
270 display: flex;
271 align-items: center;
272 justify-content: space-between;
273 padding: 8px 14px;
274 border-bottom: 1px solid rgba(255,255,255,0.07);
275}
276.ob-term-label {
277 font-family: 'JetBrains Mono', monospace;
278 font-size: 10.5px;
279 color: #8a8d99;
280 letter-spacing: 0.08em;
281 text-transform: uppercase;
282}
283.ob-copy-btn {
284 padding: 4px 12px;
285 border-radius: 6px;
286 font-size: 11px;
287 font-weight: 600;
288 border: 1px solid rgba(255,255,255,0.14);
289 background: transparent;
290 color: #d6d8e0;
291 cursor: pointer;
292 font-family: inherit;
293 transition: background 0.1s;
294}
295.ob-copy-btn:hover { background: rgba(255,255,255,0.06); }
296.ob-term-body {
297 padding: 12px 16px;
298 font-family: 'JetBrains Mono', monospace;
299 font-size: 12px;
300 line-height: 1.8;
301 color: #d6d8e0;
302}
303.ob-term-prompt { color: var(--accent-2); }
304.ob-term-comment { color: #8a8d99; }
305
306/* ── connection status row ───────────────────────────────────────────────── */
307.ob-conn {
308 display: flex;
309 align-items: center;
310 gap: 8px;
311 margin-top: 10px;
312 font-size: 12.5px;
313}
314.ob-conn-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
315.ob-conn-text { flex: 1; }
316.ob-conn-btn {
317 margin-left: 8px;
318 padding: 3px 12px;
319 border-radius: 6px;
320 font-size: 11.5px;
321 font-weight: 600;
322 border: 1px solid rgba(22,24,29,0.14);
323 background: #ffffff;
324 color: #16181d;
325 cursor: pointer;
326 font-family: inherit;
327 white-space: nowrap;
328}
329.ob-conn-btn:hover { background: #f5f5f7; }
330
331/* ── wizard footer / CTA ─────────────────────────────────────────────────── */
332.ob-wiz-footer {
333 padding: 16px 26px;
334 border-top: 1px solid rgba(22,24,29,0.06);
335 display: flex;
336 align-items: center;
337 gap: 12px;
338 flex-wrap: wrap;
339}
340.ob-cta-btn {
341 padding: 9px 18px;
342 border-radius: 9px;
343 font-size: 13.5px;
344 font-weight: 600;
345 background: #16181d;
346 color: #fff;
347 text-decoration: none;
348 white-space: nowrap;
349 display: inline-block;
350}
351.ob-cta-btn:hover { opacity: 0.88; }
352.ob-cta-note { font-size: 12px; color: #8a8d99; }
353
354/* ── right rail ──────────────────────────────────────────────────────────── */
355.ob-rail {
356 display: flex;
357 flex-direction: column;
358 gap: 32px;
359 position: sticky;
360 top: 92px;
361}
362.ob-rail-hed {
363 font-family: 'JetBrains Mono', monospace;
364 font-size: 11px;
365 letter-spacing: 0.12em;
366 text-transform: uppercase;
367 color: #8a8d99;
368 font-weight: 500;
369 margin: 0 0 14px;
370}
371
372/* ── sensed card ─────────────────────────────────────────────────────────── */
373.ob-sensed-card {
374 border: 1px solid rgba(22,24,29,0.07);
375 border-radius: 12px;
376 background: #ffffff;
377 padding: 20px;
378 display: flex;
379 flex-direction: column;
380 gap: 11px;
381}
382.ob-sensed-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; }
383.ob-sensed-k { color: #6b7080; }
384.ob-sensed-v {
385 font-family: 'JetBrains Mono', monospace;
386 font-size: 11.5px;
387 color: #16181d;
388 text-align: right;
389}
390
391/* ── invariant cards ─────────────────────────────────────────────────────── */
392.ob-invs { display: flex; flex-direction: column; gap: 10px; }
393.ob-inv {
394 border: 1px solid rgba(22,24,29,0.07);
395 border-radius: 12px;
396 background: #ffffff;
397 padding: 14px 18px;
398}
399.ob-inv-title { font-size: 13px; font-weight: 600; color: #16181d; margin-bottom: 3px; }
400.ob-inv-body { font-size: 12px; color: #6b7080; margin: 0; line-height: 1.55; }
401
402/* ── responsive ──────────────────────────────────────────────────────────── */
403@media (max-width: 860px) {
404 .ob-grid { grid-template-columns: 1fr; }
405 .ob-rail { position: static; }
406 .ob-tagline { display: none; }
407}
408`;
409
410/* ─── client script ───────────────────────────────────────────────────────── */
411
412const js = `(function () {
413 var GREEN = '#1e7f5c';
414 var AMBER = '#b45309';
415 var INDIGO = 'var(--accent)';
416 var INK = '#16181d';
417 var GREY = '#c4c6cf';
418 var PULSE = 'gcPulse 1.4s ease-in-out infinite';
419
420 var SITUATIONS = [
421 {
422 id: 'broken-codebase',
423 label: 'Broken codebase import',
424 headDot: AMBER,
425 headTitle: 'Auto-Repair Mode engaged',
426 headBody: 'Your import arrived with 7 failing tests and a missing config. Instead of a red build bar, your first experience is a fix.',
427 steps: [
428 {
429 title: 'Repo imported · full history + branches',
430 body: '142 commits, 3 branches, semantic index building in the background.',
431 dot: GREEN, snippet: false
432 },
433 {
434 title: 'Archaeology scan complete',
435 body: 'Tech-debt hotspots flagged, symbols mapped, architecture topology drawn — your team gets the map on day one.',
436 dot: GREEN, snippet: false
437 },
438 {
439 title: 'Auto-repair drafted your first patch',
440 body: 'The healer reproduced the failures in a sandbox and drafted a fix for the missing config and 5 of 7 tests. Review it like any PR — or fix it manually.',
441 dot: AMBER, snippet: false
442 },
443 {
444 title: 'One-shot local setup',
445 body: 'Configures git, injects host keys, and tests the connection — no manual terminal spelunking.',
446 dot: INK, snippet: true
447 }
448 ],
449 ctaLabel: 'Review your first repair PR',
450 ctaHref: '/',
451 ctaNote: 'Nothing merges without you — the manual diff is one click away.',
452 sensed: [
453 { k: 'Import source', v: 'github mirror' },
454 { k: 'Build status', v: '7 tests failing' },
455 { k: 'Config', v: '.env.example missing' },
456 { k: 'Flow selected', v: 'auto-repair mode' }
457 ]
458 },
459 {
460 id: 'agent-teammate',
461 label: 'Agent teammate',
462 headDot: INDIGO,
463 headTitle: 'Machine user provisioning',
464 headBody: 'A non-human teammate is joining. No email drips, no UI tours — scoped credentials and machine-speed rails, immediately.',
465 steps: [
466 {
467 title: 'Bot identity created · migration-pilot',
468 body: 'Scoped API token minted with exactly the permissions its manifest declares — nothing more.',
469 dot: GREEN, snippet: false
470 },
471 {
472 title: 'Personal semantic space seeded',
473 body: 'The agent gets its own index scope so its queries never leak across tenants.',
474 dot: GREEN, snippet: false
475 },
476 {
477 title: 'Fast-lane execution rights',
478 body: 'Trusted-agent patches skip human-task scheduling; agent-to-agent verification runs at machine speed — still gated, still audited.',
479 dot: INDIGO, snippet: false
480 },
481 {
482 title: 'Reversibility contract signed',
483 body: 'Every action the agent takes obeys the same one-click-undo guarantee as ours. No exceptions for bots.',
484 dot: INK, snippet: false
485 }
486 ],
487 ctaLabel: 'See agents at work',
488 ctaHref: '/',
489 ctaNote: 'Every agent action lands in the same audit trail as human ones.',
490 sensed: [
491 { k: 'Account type', v: 'machine user' },
492 { k: 'Manifest scopes', v: 'repo:write, pr:open' },
493 { k: 'Drip sequence', v: 'skipped' },
494 { k: 'Flow selected', v: 'fast-lane provisioning' }
495 ]
496 },
497 {
498 id: 'air-gapped-enterprise',
499 label: 'Air-gapped enterprise',
500 headDot: INK,
501 headTitle: 'Sovereign tenant setup',
502 headBody: 'This tenant is flagged bare-metal / private-cloud. Public identity options are gone; everything stays inside your network boundary.',
503 steps: [
504 {
505 title: 'Public OAuth removed',
506 body: 'GitHub and Google sign-in don\'t appear. Mandatory SSH key + passkey registration, enforced at the door.',
507 dot: INK, snippet: false
508 },
509 {
510 title: 'Local signing keys seeded',
511 body: 'Commit and artifact signing keys generated on your hardware — verification never phones home.',
512 dot: GREEN, snippet: false
513 },
514 {
515 title: 'OCI registry fenced to your perimeter',
516 body: 'Container builds, package caches, and signed binaries route entirely inside your Caddy boundary. Zero external CDN or proxy.',
517 dot: GREEN, snippet: false
518 },
519 {
520 title: 'Air-gapped sync scheduled',
521 body: 'Repos, semantic indices, and registries replicate hourly to your offline array.',
522 dot: INK, snippet: false
523 }
524 ],
525 ctaLabel: 'Inspect the sovereign stack',
526 ctaHref: '/',
527 ctaNote: 'All 13 production layers, self-owned and self-healing.',
528 sensed: [
529 { k: 'Tenant flag', v: 'air-gapped' },
530 { k: 'Public OAuth', v: 'disabled' },
531 { k: 'Registry scope', v: 'local network only' },
532 { k: 'Flow selected', v: 'sovereign setup' }
533 ]
534 },
535 {
536 id: 'just-browsing',
537 label: 'Just browsing',
538 headDot: GREEN,
539 headTitle: 'Playground account · zero strings',
540 headBody: 'No migration, no commitment. An ephemeral, isolated sandbox with everything switched on — expires on its own unless you keep it.',
541 steps: [
542 {
543 title: 'Instant sandbox account',
544 body: 'No email required. A demo repo with real history, gates, and an agent ready to talk.',
545 dot: GREEN, snippet: false
546 },
547 {
548 title: 'Try the whole loop',
549 body: 'Push a change, watch gates fire at the wire, see Sonnet 5 review it, break something and watch the repair.',
550 dot: GREEN, snippet: false
551 },
552 {
553 title: 'Keep it when you\'re ready',
554 body: 'Claiming your account bridges everything — your sandbox history, repos, and sessions come with you. Nothing resets.',
555 dot: INDIGO, snippet: false
556 }
557 ],
558 ctaLabel: 'Enter the playground',
559 ctaHref: '/',
560 ctaNote: 'Expires in 24h unless claimed — claim keeps all history.',
561 sensed: [
562 { k: 'Signup', v: 'none — anonymous' },
563 { k: 'Environment', v: 'ephemeral sandbox' },
564 { k: 'Expiry', v: '24h · claimable' },
565 { k: 'Flow selected', v: 'playground' }
566 ]
567 }
568 ];
569
570 /* ── state ─────────────────────────────────────────────────────────────── */
571 var state = { situation: 0, copied: false, conn: 'idle' };
572
573 var root = document.querySelector('.ob-root');
574 if (root) {
575 var initSit = root.getAttribute('data-init-situation') || 'broken-codebase';
576 for (var si = 0; si < SITUATIONS.length; si++) {
577 if (SITUATIONS[si].id === initSit) { state.situation = si; break; }
578 }
579 }
580
581 function setState(patch) {
582 for (var k in patch) { if (Object.prototype.hasOwnProperty.call(patch, k)) state[k] = patch[k]; }
583 render();
584 }
585
586 /* ── html escaping ─────────────────────────────────────────────────────── */
587 function esc(str) {
588 return String(str)
589 .replace(/&/g, '&amp;')
590 .replace(/</g, '&lt;')
591 .replace(/>/g, '&gt;')
592 .replace(/"/g, '&quot;');
593 }
594
595 /* ── render pills ──────────────────────────────────────────────────────── */
596 function renderPills() {
597 var el = document.getElementById('ob-pills');
598 if (!el) return;
599 var html = '';
600 for (var i = 0; i < SITUATIONS.length; i++) {
601 var active = i === state.situation;
602 html += '<button type="button" class="ob-pill' + (active ? ' ob-pill--active' : '') +
603 '" data-sit="' + i + '">' + esc(SITUATIONS[i].label) + '</button>';
604 }
605 el.innerHTML = html;
606 var pills = el.querySelectorAll('.ob-pill');
607 for (var j = 0; j < pills.length; j++) {
608 (function (pill) {
609 pill.addEventListener('click', function () {
610 setState({ situation: parseInt(pill.getAttribute('data-sit'), 10), copied: false, conn: 'idle' });
611 });
612 })(pills[j]);
613 }
614 }
615
616 /* ── render wizard header ──────────────────────────────────────────────── */
617 function renderWizHead() {
618 var el = document.getElementById('ob-wiz-head');
619 if (!el) return;
620 var d = SITUATIONS[state.situation];
621 el.innerHTML =
622 '<div class="ob-wiz-head-row">' +
623 '<span class="ob-wiz-dot" style="background:' + d.headDot + '"></span>' +
624 '<span class="ob-wiz-title">' + esc(d.headTitle) + '</span>' +
625 '</div>' +
626 '<p class="ob-wiz-body">' + esc(d.headBody) + '</p>';
627 }
628
629 /* ── render steps ──────────────────────────────────────────────────────── */
630 function renderSteps() {
631 var el = document.getElementById('ob-wiz-steps');
632 if (!el) return;
633 var d = SITUATIONS[state.situation];
634 var html = '';
635
636 for (var i = 0; i < d.steps.length; i++) {
637 var s = d.steps[i];
638 var isLast = i === d.steps.length - 1;
639 var snippetHtml = '';
640
641 if (s.snippet) {
642 var copyLabel = state.copied ? 'Copied ✓' : 'Copy';
643 var connLabel = state.conn === 'ok'
644 ? 'SSH + OAuth verified — direct to sovereign array, no external edge'
645 : state.conn === 'testing'
646 ? 'Testing local → sovereign connection…'
647 : 'Connection not yet tested';
648 var connDot = state.conn === 'ok' ? GREEN : state.conn === 'testing' ? AMBER : GREY;
649 var connColor = state.conn === 'ok' ? GREEN : '#6b7080';
650 var connAnim = state.conn === 'testing' ? PULSE : 'none';
651 var testLabel = state.conn === 'testing' ? 'Testing…'
652 : state.conn === 'ok' ? 'Re-test'
653 : 'Test connection';
654
655 snippetHtml =
656 '<div class="ob-term">' +
657 '<div class="ob-term-bar">' +
658 '<span class="ob-term-label">one-shot local setup</span>' +
659 '<button type="button" class="ob-copy-btn" id="ob-copy-btn">' + esc(copyLabel) + '</button>' +
660 '</div>' +
661 '<div class="ob-term-body">' +
662 '<div><span class="ob-term-prompt">$</span> curl -sSL gluecron.com/setup | bash</div>' +
663 '<div class="ob-term-comment"># configures git · injects host keys · tests the connection</div>' +
664 '</div>' +
665 '</div>' +
666 '<div class="ob-conn" style="color:' + connColor + '">' +
667 '<span class="ob-conn-dot" style="background:' + connDot + ';animation:' + connAnim + '"></span>' +
668 '<span class="ob-conn-text">' + esc(connLabel) + '</span>' +
669 '<button type="button" class="ob-conn-btn" id="ob-conn-btn">' + esc(testLabel) + '</button>' +
670 '</div>';
671 }
672
673 html +=
674 '<div class="ob-step">' +
675 '<div class="ob-step-track">' +
676 '<span class="ob-step-dot" style="background:' + s.dot + '"></span>' +
677 (!isLast ? '<span class="ob-step-line"></span>' : '') +
678 '</div>' +
679 '<div>' +
680 '<div class="ob-step-title">' + esc(s.title) + '</div>' +
681 '<p class="ob-step-body">' + esc(s.body) + '</p>' +
682 snippetHtml +
683 '</div>' +
684 '</div>';
685 }
686
687 el.innerHTML = html;
688
689 /* copy button */
690 var copyBtn = document.getElementById('ob-copy-btn');
691 if (copyBtn) {
692 copyBtn.addEventListener('click', function () {
693 try { navigator.clipboard.writeText('curl -sSL https://gluecron.com/setup | bash'); } catch (e) {}
694 setState({ copied: true });
695 setTimeout(function () { setState({ copied: false }); }, 1200);
696 });
697 }
698
699 /* test-connection button */
700 var connBtn = document.getElementById('ob-conn-btn');
701 if (connBtn) {
702 connBtn.addEventListener('click', function () {
703 if (state.conn === 'testing') return;
704 setState({ conn: 'testing' });
705 setTimeout(function () { setState({ conn: 'ok' }); }, 1600);
706 });
707 }
708 }
709
710 /* ── render footer / CTA ───────────────────────────────────────────────── */
711 function renderFooter() {
712 var el = document.getElementById('ob-wiz-footer');
713 if (!el) return;
714 var d = SITUATIONS[state.situation];
715 el.innerHTML =
716 '<a href="' + esc(d.ctaHref) + '" class="ob-cta-btn">' + esc(d.ctaLabel) + '</a>' +
717 '<span class="ob-cta-note">' + esc(d.ctaNote) + '</span>';
718 }
719
720 /* ── render sensed card ────────────────────────────────────────────────── */
721 function renderSensed() {
722 var el = document.getElementById('ob-sensed-card');
723 if (!el) return;
724 var d = SITUATIONS[state.situation];
725 var html = '';
726 for (var i = 0; i < d.sensed.length; i++) {
727 html +=
728 '<div class="ob-sensed-row">' +
729 '<span class="ob-sensed-k">' + esc(d.sensed[i].k) + '</span>' +
730 '<span class="ob-sensed-v">' + esc(d.sensed[i].v) + '</span>' +
731 '</div>';
732 }
733 el.innerHTML = html;
734 }
735
736 /* ── full render ───────────────────────────────────────────────────────── */
737 function render() {
738 renderPills();
739 renderWizHead();
740 renderSteps();
741 renderFooter();
742 renderSensed();
743 }
744
745 if (document.readyState === 'loading') {
746 document.addEventListener('DOMContentLoaded', render);
747 } else {
748 render();
749 }
750})();`;
Deletedsrc/views/plan-approval.tsx+0−773View fileUnifiedSplit
1import type { FC } from "hono/jsx";
2
3export interface PlanStep {
4 n: string;
5 title: string;
6 desc: string;
7}
8
9export interface Plan {
10 id: number | string;
11 spec: string;
12 steps: PlanStep[];
13}
14
15export interface PlanApprovalProps {
16 plan?: Plan;
17 blastRadius?: any;
18 costEstimate?: any;
19 user?: any;
20}
21
22const DEFAULT_STEPS = [
23 {
24 n: "01",
25 title: "Add plan-lapse state to the schema",
26 detail:
27 "New enum value 'lapsed' + lapsedAt timestamp. Additive migration, no backfill needed.",
28 files: "drizzle/0108_plan_lapse.sql · src/db/schema.ts",
29 },
30 {
31 n: "02",
32 title: "Enforce read-only on lapsed orgs",
33 detail:
34 "Git push and web writes rejected with a clear message; clone, fetch, and browsing stay open. Internal orgs short-circuit before any check.",
35 files: "src/middleware/plan-guard.ts · src/git/protocol.ts",
36 },
37 {
38 n: "03",
39 title: "Warning emails at 7 days and 1 day",
40 detail:
41 "Two templated emails via the existing notify pipeline, deduped per org, logged to the audit trail.",
42 files: "src/lib/notify.ts · src/lib/autopilot.ts",
43 },
44 {
45 n: "04",
46 title: "Admin visibility",
47 detail:
48 "Lapsed orgs surface in the customers table with days-remaining, next to the existing exempt/current states.",
49 files: "src/routes/admin.tsx",
50 },
51 {
52 n: "05",
53 title: "Tests",
54 detail:
55 "12 new tests: guard behavior, internal-org exemption, email dedupe, migration idempotence.",
56 files: "src/__tests__/plan-guard.test.ts",
57 },
58];
59
60const DEFAULT_SPEC =
61 "When a customer's plan lapses, keep their repos read-only instead of blocking clone access. Send one warning email at 7 days and one at 1 day. Internal orgs are never affected.";
62
63const STEP_COUNT = DEFAULT_STEPS.length;
64
65const inlineJs = `
66(function () {
67 var manual = {};
68 var approved = false;
69 var editing = false;
70 var STEP_COUNT = ${STEP_COUNT};
71
72 function getManualCount() {
73 var n = 0;
74 for (var k in manual) { if (manual[k]) n++; }
75 return n;
76 }
77
78 function updateUI() {
79 var manualCount = getManualCount();
80 var agentSteps = STEP_COUNT - manualCount;
81
82 for (var i = 0; i < STEP_COUNT; i++) {
83 var btn = document.getElementById('pa-step-btn-' + i);
84 if (!btn) continue;
85 if (manual[i]) {
86 btn.textContent = 'Yours ✓';
87 btn.style.border = '1px solid color-mix(in srgb, var(--accent) 35%, transparent)';
88 btn.style.background = 'color-mix(in srgb, var(--accent) 6%, transparent)';
89 btn.style.color = 'var(--accent)';
90 } else {
91 btn.textContent = "I’ll do this";
92 btn.style.border = '1px solid rgba(22,24,29,0.12)';
93 btn.style.background = '#ffffff';
94 btn.style.color = '#6b7080';
95 }
96 }
97
98 var filesTouched = document.getElementById('pa-files-touched');
99 if (filesTouched) filesTouched.textContent = String(9 - manualCount);
100
101 var yourTime = document.getElementById('pa-your-time');
102 if (yourTime) {
103 yourTime.textContent =
104 manualCount > 0
105 ? 'review + ' + manualCount + ' manual step' + (manualCount > 1 ? 's' : '')
106 : '≈ 10 min review';
107 }
108
109 var approveBtn = document.getElementById('pa-approve-btn');
110 if (approveBtn) {
111 if (approved) {
112 approveBtn.textContent = 'Plan approved — agent starting';
113 approveBtn.style.background = '#1e7f5c';
114 } else if (manualCount > 0) {
115 approveBtn.textContent = 'Approve plan (' + agentSteps + ' agent step' + (agentSteps !== 1 ? 's' : '') + ')';
116 approveBtn.style.background = '#16181d';
117 } else {
118 approveBtn.textContent = 'Approve plan';
119 approveBtn.style.background = '#16181d';
120 }
121 }
122 }
123
124 for (var i = 0; i < STEP_COUNT; i++) {
125 (function (idx) {
126 var btn = document.getElementById('pa-step-btn-' + idx);
127 if (btn) {
128 btn.addEventListener('click', function () {
129 manual[idx] = !manual[idx];
130 updateUI();
131 });
132 }
133 })(i);
134 }
135
136 var approveBtn = document.getElementById('pa-approve-btn');
137 if (approveBtn) {
138 approveBtn.addEventListener('click', function () {
139 if (!approved) {
140 approved = true;
141 updateUI();
142 }
143 });
144 }
145
146 var editBtn = document.getElementById('pa-edit-btn');
147 if (editBtn) {
148 editBtn.addEventListener('click', function () {
149 editing = !editing;
150 editBtn.textContent = editing
151 ? "Editing — tap ‘I’ll do this’ on any step"
152 : 'Edit the plan';
153 });
154 }
155})();
156`;
157
158export const PlanApprovalView: FC<PlanApprovalProps> = (props) => {
159 const plan = props.plan;
160 const spec = plan?.spec ?? DEFAULT_SPEC;
161 const planId = plan?.id ?? 31;
162 const steps = DEFAULT_STEPS;
163
164 return (
165 <>
166 <style dangerouslySetInnerHTML={{ __html: css }} />
167 <div class="pa-root">
168 <header class="pa-header">
169 <a href="/" class="pa-logo">
170 <span class="pa-logo-mark"></span>
171 gluecron
172 </a>
173 <nav class="pa-breadcrumb">
174 <span class="pa-breadcrumb-item">ccantynz-alt</span>
175 <span class="pa-breadcrumb-sep">/</span>
176 <span class="pa-breadcrumb-item">gluecron</span>
177 <span class="pa-breadcrumb-sep">/</span>
178 <span class="pa-breadcrumb-item pa-breadcrumb-active">Specs</span>
179 </nav>
180 <div class="pa-header-right">
181 <span class="pa-avatar">C</span>
182 </div>
183 </header>
184
185 <main class="pa-main">
186 <div class="pa-page-header">
187 <div class="pa-eyebrow">Spec №{planId} · Plan review</div>
188 <h1 class="pa-title">Approve the plan, not just the PR.</h1>
189 <p class="pa-subtitle">
190 Before a single line is written, here is exactly what the agent intends to do — the
191 blast radius, the cost, and how to undo it. Approve it, edit it, or take any piece of
192 it manually.
193 </p>
194 </div>
195
196 <div class="pa-grid">
197 {/* Left: the plan */}
198 <section class="pa-left">
199 {/* Spec quote card */}
200 <div class="pa-spec-card">
201 <div class="pa-spec-label">Your spec — as written</div>
202 <p class="pa-spec-quote">"{spec}"</p>
203 </div>
204
205 {/* Plan steps */}
206 <h2 class="pa-steps-heading">The plan · {steps.length} steps</h2>
207 <div class="pa-steps-list">
208 {steps.map((step, i) => (
209 <div class="pa-step-row" id={"pa-step-row-" + i}>
210 <span class="pa-step-num">{step.n}</span>
211 <div class="pa-step-body">
212 <div class="pa-step-title">{step.title}</div>
213 <div class="pa-step-detail">{step.detail}</div>
214 <div class="pa-step-files">{step.files}</div>
215 </div>
216 <button
217 type="button"
218 id={"pa-step-btn-" + i}
219 class="pa-step-btn"
220 >
221 I'll do this
222 </button>
223 </div>
224 ))}
225 </div>
226 <p class="pa-steps-note">
227 Steps marked{" "}
228 <strong class="pa-steps-note-em">I'll do this</strong> are left out of the agent's
229 branch — the PR arrives with those files untouched and a checklist for you.
230 </p>
231 </section>
232
233 {/* Right: aside */}
234 <aside class="pa-aside">
235 {/* Blast radius */}
236 <div class="pa-section">
237 <h3 class="pa-section-label">Blast radius</h3>
238 <div class="pa-card">
239 <div class="pa-card-rows">
240 <div class="pa-card-row">
241 <span class="pa-card-key">Files touched</span>
242 <span class="pa-card-val pa-mono" id="pa-files-touched">
243 9
244 </span>
245 </div>
246 <div class="pa-card-row">
247 <span class="pa-card-key">Repos affected</span>
248 <span class="pa-card-val pa-mono">1 · gluecron</span>
249 </div>
250 <div class="pa-card-row">
251 <span class="pa-card-key">DB migration</span>
252 <span class="pa-card-val pa-mono pa-amber">1 · additive only</span>
253 </div>
254 <div class="pa-card-row">
255 <span class="pa-card-key">Public API change</span>
256 <span class="pa-card-val pa-mono pa-green">none</span>
257 </div>
258 <div class="pa-card-row">
259 <span class="pa-card-key">Tests to write</span>
260 <span class="pa-card-val pa-mono">12</span>
261 </div>
262 </div>
263 <div class="pa-divider"></div>
264 <p class="pa-card-note">
265 <strong class="pa-card-note-em">Downstream:</strong> billing cron, email
266 digests, and the admin customers table read the plan-state column this plan
267 modifies.
268 </p>
269 </div>
270 </div>
271
272 {/* Cost before code */}
273 <div class="pa-section">
274 <h3 class="pa-section-label">Cost before code</h3>
275 <div class="pa-card">
276 <div class="pa-card-rows">
277 <div class="pa-card-row">
278 <span class="pa-card-key">Estimated AI spend</span>
279 <span class="pa-card-val pa-mono">$1.10 – $1.80</span>
280 </div>
281 <div class="pa-card-row">
282 <span class="pa-card-key">Model routing</span>
283 <span class="pa-card-val pa-mono">local first</span>
284 </div>
285 <div class="pa-card-row">
286 <span class="pa-card-key">Sandbox time</span>
287 <span class="pa-card-val pa-mono">≈ 25 min</span>
288 </div>
289 <div class="pa-card-row">
290 <span class="pa-card-key">Your time needed</span>
291 <span class="pa-card-val pa-mono" id="pa-your-time">
292 ≈ 10 min review
293 </span>
294 </div>
295 </div>
296 </div>
297 </div>
298
299 {/* How this reverses */}
300 <div class="pa-section">
301 <h3 class="pa-section-label">How this reverses</h3>
302 <div class="pa-card">
303 <p class="pa-revert-text">
304 Single revert commit restores all code paths. The migration is additive, so
305 rollback needs no data repair. Warning emails already sent are logged but cannot
306 be unsent — flagged as this plan's only irreversible effect.
307 </p>
308 </div>
309 </div>
310
311 {/* Actions */}
312 <div class="pa-actions">
313 <button type="button" id="pa-approve-btn" class="pa-btn-primary">
314 Approve plan
315 </button>
316 <button type="button" id="pa-edit-btn" class="pa-btn-secondary">
317 Edit the plan
318 </button>
319 <p class="pa-disclaimer">Nothing is written until you approve.</p>
320 </div>
321 </aside>
322 </div>
323 </main>
324 </div>
325 <script dangerouslySetInnerHTML={{ __html: inlineJs }} />
326 </>
327 );
328};
329
330export default PlanApprovalView;
331
332const css = `
333@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=Inter+Tight:wght@500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
334
335*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
336
337.pa-root {
338 font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
339 font-size: 14px;
340 line-height: 1.55;
341 letter-spacing: -0.008em;
342 color: #16181d;
343 background: #fcfcfd;
344 min-height: 100vh;
345 display: flex;
346 flex-direction: column;
347 -webkit-font-smoothing: antialiased;
348 text-rendering: optimizeLegibility;
349}
350
351/* ── Header ─────────────────────────────────────────────── */
352
353.pa-header {
354 height: 56px;
355 border-bottom: 1px solid rgba(22,24,29,0.07);
356 background: #fcfcfd;
357 display: flex;
358 align-items: center;
359 padding: 0 28px;
360 gap: 20px;
361 position: sticky;
362 top: 0;
363 z-index: 50;
364}
365
366.pa-logo {
367 display: inline-flex;
368 align-items: center;
369 gap: 9px;
370 font-family: 'Inter Tight', sans-serif;
371 font-weight: 600;
372 font-size: 15px;
373 letter-spacing: -0.02em;
374 color: #16181d;
375 text-decoration: none;
376 flex-shrink: 0;
377}
378
379.pa-logo-mark {
380 width: 16px;
381 height: 16px;
382 border-radius: 5px;
383 background: var(--accent);
384 display: inline-block;
385 flex-shrink: 0;
386}
387
388.pa-breadcrumb {
389 display: flex;
390 align-items: center;
391 gap: 4px;
392 font-size: 13px;
393 color: #6b7080;
394}
395
396.pa-breadcrumb-item {
397 padding: 5px 8px;
398}
399
400.pa-breadcrumb-sep {
401 color: #c4c6cf;
402}
403
404.pa-breadcrumb-active {
405 color: #16181d;
406 font-weight: 500;
407}
408
409.pa-header-right {
410 margin-left: auto;
411 display: flex;
412 align-items: center;
413 gap: 14px;
414}
415
416.pa-avatar {
417 width: 28px;
418 height: 28px;
419 border-radius: 50%;
420 background: #16181d;
421 color: #fff;
422 font-size: 11px;
423 font-weight: 600;
424 display: inline-flex;
425 align-items: center;
426 justify-content: center;
427 flex-shrink: 0;
428}
429
430/* ── Main ───────────────────────────────────────────────── */
431
432.pa-main {
433 flex: 1;
434 max-width: 1180px;
435 width: 100%;
436 margin: 0 auto;
437 padding: 44px 32px 80px;
438}
439
440/* ── Page header ────────────────────────────────────────── */
441
442.pa-page-header {
443 max-width: 720px;
444 margin-bottom: 40px;
445}
446
447.pa-eyebrow {
448 font-family: 'JetBrains Mono', monospace;
449 font-size: 11px;
450 letter-spacing: 0.12em;
451 text-transform: uppercase;
452 color: #8a8d99;
453 margin-bottom: 10px;
454}
455
456.pa-title {
457 font-family: 'Inter Tight', sans-serif;
458 font-size: 28px;
459 font-weight: 600;
460 letter-spacing: -0.025em;
461 line-height: 1.15;
462 color: #111318;
463 margin: 0 0 10px;
464}
465
466.pa-subtitle {
467 font-size: 14px;
468 color: #6b7080;
469 line-height: 1.6;
470}
471
472/* ── Two-column grid ────────────────────────────────────── */
473
474.pa-grid {
475 display: grid;
476 grid-template-columns: minmax(0, 1.5fr) minmax(300px, 1fr);
477 gap: 44px;
478 align-items: start;
479}
480
481/* ── Left column ────────────────────────────────────────── */
482
483.pa-left {}
484
485/* Spec quote card */
486
487.pa-spec-card {
488 border: 1px solid rgba(22,24,29,0.07);
489 border-left: 3px solid var(--accent);
490 border-radius: 12px;
491 background: #ffffff;
492 padding: 20px 24px;
493 margin-bottom: 28px;
494}
495
496.pa-spec-label {
497 font-family: 'JetBrains Mono', monospace;
498 font-size: 11px;
499 letter-spacing: 0.1em;
500 text-transform: uppercase;
501 color: #8a8d99;
502 font-weight: 500;
503 margin-bottom: 10px;
504}
505
506.pa-spec-quote {
507 font-size: 14px;
508 color: #16181d;
509 line-height: 1.65;
510 font-style: italic;
511}
512
513/* Plan steps */
514
515.pa-steps-heading {
516 font-family: 'Inter Tight', sans-serif;
517 font-size: 15px;
518 font-weight: 600;
519 letter-spacing: -0.015em;
520 color: #16181d;
521 margin: 0 0 16px;
522}
523
524.pa-steps-list {
525 display: flex;
526 flex-direction: column;
527 margin-bottom: 8px;
528}
529
530.pa-step-row {
531 display: grid;
532 grid-template-columns: 24px minmax(0, 1fr) auto;
533 gap: 14px;
534 padding: 14px 4px;
535 border-bottom: 1px solid rgba(22,24,29,0.06);
536 align-items: start;
537}
538
539.pa-step-num {
540 font-family: 'JetBrains Mono', monospace;
541 font-size: 12px;
542 color: #8a8d99;
543 padding-top: 2px;
544 line-height: 1.55;
545}
546
547.pa-step-body {
548 min-width: 0;
549}
550
551.pa-step-title {
552 font-size: 14px;
553 font-weight: 500;
554 color: #16181d;
555 line-height: 1.4;
556}
557
558.pa-step-detail {
559 font-size: 12.5px;
560 color: #6b7080;
561 margin-top: 2px;
562 line-height: 1.5;
563}
564
565.pa-step-files {
566 font-family: 'JetBrains Mono', monospace;
567 font-size: 11.5px;
568 color: #8a8d99;
569 margin-top: 6px;
570 line-height: 1.5;
571}
572
573.pa-step-btn {
574 padding: 5px 12px;
575 border-radius: 7px;
576 font-size: 12px;
577 font-weight: 500;
578 border: 1px solid rgba(22,24,29,0.12);
579 background: #ffffff;
580 color: #6b7080;
581 cursor: pointer;
582 font-family: inherit;
583 white-space: nowrap;
584 transition: border-color 0.12s, background 0.12s, color 0.12s;
585 flex-shrink: 0;
586 align-self: start;
587 margin-top: 2px;
588}
589
590.pa-step-btn:hover {
591 border-color: rgba(22,24,29,0.22);
592}
593
594.pa-steps-note {
595 font-size: 12.5px;
596 color: #8a8d99;
597 margin: 10px 0 0;
598 line-height: 1.6;
599}
600
601.pa-steps-note-em {
602 color: #16181d;
603 font-weight: 500;
604}
605
606/* ── Right aside ────────────────────────────────────────── */
607
608.pa-aside {
609 display: flex;
610 flex-direction: column;
611 gap: 32px;
612 position: sticky;
613 top: 92px;
614}
615
616.pa-section {}
617
618.pa-section-label {
619 font-family: 'JetBrains Mono', monospace;
620 font-size: 11px;
621 letter-spacing: 0.12em;
622 text-transform: uppercase;
623 color: #8a8d99;
624 font-weight: 500;
625 margin: 0 0 14px;
626 display: block;
627}
628
629.pa-card {
630 border: 1px solid rgba(22,24,29,0.07);
631 border-radius: 12px;
632 background: #ffffff;
633 padding: 20px;
634}
635
636.pa-card-rows {
637 display: flex;
638 flex-direction: column;
639 gap: 12px;
640}
641
642.pa-card-row {
643 display: flex;
644 justify-content: space-between;
645 align-items: baseline;
646 font-size: 13px;
647 gap: 12px;
648}
649
650.pa-card-key {
651 color: #6b7080;
652 flex-shrink: 0;
653}
654
655.pa-card-val {
656 color: #16181d;
657 text-align: right;
658}
659
660.pa-mono {
661 font-family: 'JetBrains Mono', monospace;
662 font-variant-numeric: tabular-nums;
663}
664
665.pa-amber {
666 color: #b45309;
667}
668
669.pa-green {
670 color: #1e7f5c;
671}
672
673.pa-divider {
674 height: 1px;
675 background: rgba(22,24,29,0.06);
676 margin: 16px 0;
677}
678
679.pa-card-note {
680 font-size: 12.5px;
681 color: #6b7080;
682 line-height: 1.6;
683}
684
685.pa-card-note-em {
686 color: #16181d;
687 font-weight: 500;
688}
689
690.pa-revert-text {
691 font-size: 12.5px;
692 color: #6b7080;
693 line-height: 1.65;
694}
695
696/* ── Actions ────────────────────────────────────────────── */
697
698.pa-actions {
699 display: flex;
700 flex-direction: column;
701 gap: 10px;
702}
703
704.pa-btn-primary {
705 padding: 10px 16px;
706 border-radius: 9px;
707 font-size: 13.5px;
708 font-weight: 500;
709 border: none;
710 background: #16181d;
711 color: #fff;
712 cursor: pointer;
713 font-family: inherit;
714 white-space: nowrap;
715 transition: background 0.18s;
716 text-align: center;
717 display: block;
718 width: 100%;
719}
720
721.pa-btn-primary:hover {
722 background: #2a2d38;
723}
724
725.pa-btn-secondary {
726 padding: 10px 16px;
727 border-radius: 9px;
728 font-size: 13.5px;
729 font-weight: 500;
730 border: 1px solid rgba(22,24,29,0.12);
731 background: #ffffff;
732 color: #16181d;
733 cursor: pointer;
734 font-family: inherit;
735 white-space: nowrap;
736 transition: border-color 0.12s;
737 text-align: center;
738 display: block;
739 width: 100%;
740}
741
742.pa-btn-secondary:hover {
743 border-color: rgba(22,24,29,0.24);
744}
745
746.pa-disclaimer {
747 font-size: 12px;
748 color: #8a8d99;
749 margin: 2px 0 0;
750 text-align: center;
751 line-height: 1.5;
752}
753
754/* ── Responsive ─────────────────────────────────────────── */
755
756@media (max-width: 820px) {
757 .pa-grid {
758 grid-template-columns: 1fr;
759 }
760 .pa-aside {
761 position: static;
762 }
763}
764
765@media (max-width: 600px) {
766 .pa-main {
767 padding: 32px 20px 60px;
768 }
769 .pa-header {
770 padding: 0 20px;
771 }
772}
773`;
Deletedsrc/views/pr-redesign.tsx+0−1511View fileUnifiedSplit
Large file (1,512 lines). Load full file
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts