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

Merge pull request #116 from ccantynz-alt/claude/charming-meitner-mllb5r

Merge pull request #116 from ccantynz-alt/claude/charming-meitner-mllb5r

Claude/charming meitner mllb5r
CC LABS App committed on June 13, 2026Parents: 5ce80b3 479dcd9
18 files changed+1344192a8088579931f4d8b0c71654c704de439f5411aaa
18 changed files+1344−192
Added.mcp.json+11−0View fileUnifiedSplit
1{
2 "mcpServers": {
3 "gluecron": {
4 "type": "http",
5 "url": "https://gluecron.com/mcp",
6 "headers": {
7 "Authorization": "Bearer ${GLUECRON_PAT}"
8 }
9 }
10 }
11}
ModifiedBUILD_BIBLE.md+4−0View fileUnifiedSplit
880880- **Env/feature health panel.** `GET /admin/env-health` (site-admin) renders every env-gated feature grouped critical/recommended/optional with Configured/Missing pills — kills the "feature silently off" class of mystery. `src/lib/env-health.ts` (pure `collectEnvHealth`, secret values never exposed) + `src/routes/admin-env-health.tsx` + tests (12).
881881- **Conservative AI model routing.** `modelForTask(task)` in `src/lib/ai-client.ts`: explicit Haiku allowlist (commit-message, issue-triage, pr-triage, label-suggest — short, human-overridden outputs only); everything code-judging/code-writing stays Sonnet, unknown tasks default Sonnet. `AI_FORCE_SONNET=1` kill-switch read per-call. Call sites switched in `ai-commit-message.ts` + `ai-generators.ts`. Owner-approved 2026-06-12 ("as long as Haiku won't cause us any harm"). Future Haiku candidates (standup, smart-digest, nl-search ranking, onboarding copy) need owner sign-off. Tests: `src/__tests__/model-routing.test.ts` (14).
882882- Deploy note: migration 0105 must apply (self-deploy runs db:migrate; safe to no-op).
883
884**2026-06-13 session — trust restyle + unified automation controls:**
885- **"Calm infrastructure" restyle (owner directive: "too cyberpunk, people need to be able to trust it").** Token-level pass in `src/views/layout.tsx`: electric purple/cyan (`#8c6dff`/`#36c5d6`) → calm indigo (`#5b6ee8`) + desaturated slate-teal; glows → 1px rings; gradients near-flat; decorative orbs/pulses removed (functional status pulses kept); light theme retuned for AA contrast. All legacy token NAMES kept (aliased to calm values) so ~190 route files are untouched. `marketing.tsx` + `landing-2030.tsx` effects + copy toned from sci-fi hype to concrete, auditable claims ("built for 2030" → "built for AI-assisted teams"; absolute "nothing broken ever reaches production" → "caught before they reach production"). Layout's locked props/markup/JS contract (§4.7) unchanged; owner approval on record this session.
886- **Per-repo automation control panel (owner directive: manual vs automatic, developer's choice).** `drizzle/0106_repo_automation_settings.sql` + `repoAutomationSettings` in schema: per-repo modes off/suggest/auto for ai_review, pr_triage, issue_triage, auto_merge, ci_autofix — defaults mirror pre-0106 behavior. `src/lib/automation-settings.ts` (fail-open loader, env kill-switches stay supreme). Dispatch gates: `ai-review.ts:319`, `auto-merge.ts:318`, `ci-autofix.ts:437`+`:558` (auto mode applies the patch via the existing Apply-Fix path), `pr-triage.ts:222`, `issue-triage.ts:159`. UI at `/:owner/:repo/settings/automation` (admin-gated; also fronts the existing test-gen + dep-updater repo columns — same storage, no duplication), linked from repo settings. Tests: `src/__tests__/automation-settings.test.ts`. Deploy note: migration 0106 must apply.
ModifiedCLAUDE.md+13−2View fileUnifiedSplit
1010
1111**For any AI session working in this repo:**
1212
13- Use the Gluecron MCP server (`.claude/settings.json` already points
14 there). Tools: `gluecron_create_pr`, `gluecron_merge_pr`,
13- Use the Gluecron MCP server. It is declared in `.mcp.json` at the
14 repo root (project scope — this is the file Claude Code actually
15 loads MCP servers from, on both web/cloud sessions and the CLI; the
16 old `mcpServers` block in `.claude/settings.json` was never read).
17 Tools: `gluecron_create_pr`, `gluecron_merge_pr`,
1518 `gluecron_create_issue`, `gluecron_comment_pr`, etc. See
1619 `src/lib/mcp-tools.ts` for the full 15-tool surface.
20- **Cloud (web) sessions:** the clone comes from the GitHub mirror and
21 `git push` to origin goes through the GitHub proxy. To run a PR
22 through Gluecron: (1) push the feature branch to origin as usual,
23 (2) also push it to the canonical remote —
24 `git push https://x:${GLUECRON_PAT}@gluecron.com/ccantynz/Gluecron.com.git HEAD:<branch>`
25 (requires `gluecron.com` in the environment's network allowlist) —
26 then (3) open the PR with `gluecron_create_pr`. If gluecron.com is
27 unreachable, fall back to GitHub and say so in the session.
1728- Do NOT call any `mcp__github__*` write tool. The settings file's
1829 `permissions.deny` already blocks them; if you encounter a deny
1930 error, switch to the equivalent `gluecron_*` tool.
Addeddrizzle/0106_repo_automation_settings.sql+46−0View fileUnifiedSplit
1-- Migration 0106: Per-repo automation settings.
2--
3-- ONE row per repository controlling every push/PR/issue-time automation
4-- with a mode of 'off' | 'suggest' | 'auto'. Absence of a row means the
5-- defaults below — which exactly match pre-0106 behavior, so shipping this
6-- migration changes nothing until a user touches the settings page.
7--
8-- Env kill-switches stay supreme: a feature disabled at the environment
9-- level (missing ANTHROPIC_API_KEY, AI_LOOP_ENABLED unset, etc.) is off
10-- regardless of what this table says.
11
12CREATE TABLE IF NOT EXISTS repo_automation_settings (
13 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
14 repository_id uuid NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE,
15
16 -- AI code review on PR open. 'suggest' (default) = post advisory review
17 -- comments, the pre-0106 behavior. 'off' = skip entirely. ('auto' is
18 -- treated as 'suggest' — review comments are inherently advisory.)
19 ai_review_mode text NOT NULL DEFAULT 'suggest',
20
21 -- AI triage comment on PR open ('suggest' = current behavior; only
22 -- on/off is meaningful, 'auto' treated as on).
23 pr_triage_mode text NOT NULL DEFAULT 'suggest',
24
25 -- AI triage comment on issue create (same semantics as pr_triage_mode).
26 issue_triage_mode text NOT NULL DEFAULT 'suggest',
27
28 -- AI-gated auto-merge. 'auto' (default) = merge when a branch_protection
29 -- rule opts in AND every gate passes — the pre-0106 behavior (still
30 -- default-deny without an enable_auto_merge rule). 'suggest' = evaluate
31 -- and record the decision in the audit log but never perform the merge.
32 -- 'off' = skip evaluation entirely.
33 auto_merge_mode text NOT NULL DEFAULT 'auto',
34
35 -- CI auto-fix on failed gate runs. 'suggest' (default) = post the patch
36 -- as a PR comment with an Apply Fix button, the pre-0106 behavior.
37 -- 'auto' = also apply the patch onto a fix/ branch automatically.
38 -- 'off' = skip entirely.
39 ci_autofix_mode text NOT NULL DEFAULT 'suggest',
40
41 created_at timestamp DEFAULT now(),
42 updated_at timestamp DEFAULT now()
43);
44
45CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_automation_settings_repo
46 ON repo_automation_settings(repository_id);
Addedsrc/__tests__/automation-settings.test.ts+266−0View fileUnifiedSplit
1/**
2 * Per-repo automation settings tests (migration 0106).
3 *
4 * Covers the contract in src/lib/automation-settings.ts:
5 * - defaults when no row exists / when the DB lookup fails (fail-open)
6 * - pure mode resolution: normalizeMode, resolveEffectiveMode (env
7 * kill-switches stay supreme), isAutomationOn, settingsFromRow
8 * - dispatch-site gating with mocked settings via the same DI seams the
9 * auto-merge / ci-autofix suites use:
10 * * evaluateAutoMerge respects 'off' / 'suggest' (no DB touched)
11 * * the other dispatch sites are pinned with the readFileSync source
12 * wiring technique used by repair-flywheel-wiring.test.ts
13 *
14 * No DB, git, or Anthropic calls are made anywhere in this suite.
15 */
16
17import { describe, it, expect } from "bun:test";
18import { readFileSync } from "node:fs";
19import { join } from "node:path";
20
21import {
22 AUTOMATION_DEFAULTS,
23 getAutomationSettings,
24 isAutomationOn,
25 normalizeMode,
26 resolveEffectiveMode,
27 settingsFromRow,
28 type AutomationSettings,
29 type AutomationSettingsLoader,
30} from "../lib/automation-settings";
31import { evaluateAutoMerge } from "../lib/auto-merge";
32
33const REPO_ID = "11111111-2222-3333-4444-555555555555";
34
35function makeSettings(
36 overrides: Partial<AutomationSettings> = {}
37): AutomationSettings {
38 return { ...AUTOMATION_DEFAULTS, ...overrides };
39}
40
41/** Loader stub that records calls — the DI seam every dispatch site accepts. */
42function makeLoader(settings: AutomationSettings): {
43 loader: AutomationSettingsLoader;
44 calls: string[];
45} {
46 const calls: string[] = [];
47 return {
48 loader: async (repositoryId: string) => {
49 calls.push(repositoryId);
50 return settings;
51 },
52 calls,
53 };
54}
55
56// ---------------------------------------------------------------------------
57// Defaults
58// ---------------------------------------------------------------------------
59
60describe("AUTOMATION_DEFAULTS — match pre-0106 behavior", () => {
61 it("advisory features default to 'suggest'", () => {
62 expect(AUTOMATION_DEFAULTS.aiReviewMode).toBe("suggest");
63 expect(AUTOMATION_DEFAULTS.prTriageMode).toBe("suggest");
64 expect(AUTOMATION_DEFAULTS.issueTriageMode).toBe("suggest");
65 expect(AUTOMATION_DEFAULTS.ciAutofixMode).toBe("suggest");
66 });
67
68 it("auto-merge defaults to 'auto' (K2/K3 already merged automatically)", () => {
69 expect(AUTOMATION_DEFAULTS.autoMergeMode).toBe("auto");
70 });
71});
72
73describe("getAutomationSettings — fail-open to defaults", () => {
74 it("returns the defaults when the DB is unavailable (no row, no env)", async () => {
75 // The test runner has no DATABASE_URL, so the lazy DB proxy throws on
76 // first access — getAutomationSettings must swallow that and return
77 // the defaults rather than letting the error change behavior.
78 const settings = await getAutomationSettings(REPO_ID);
79 expect(settings).toEqual({ ...AUTOMATION_DEFAULTS });
80 });
81});
82
83// ---------------------------------------------------------------------------
84// Pure mode resolution
85// ---------------------------------------------------------------------------
86
87describe("normalizeMode", () => {
88 it("passes through the three valid modes", () => {
89 expect(normalizeMode("off", "suggest")).toBe("off");
90 expect(normalizeMode("suggest", "off")).toBe("suggest");
91 expect(normalizeMode("auto", "off")).toBe("auto");
92 });
93
94 it("falls back on garbage, undefined, and non-strings", () => {
95 expect(normalizeMode("ON", "suggest")).toBe("suggest");
96 expect(normalizeMode(undefined, "auto")).toBe("auto");
97 expect(normalizeMode(null, "off")).toBe("off");
98 expect(normalizeMode(1, "suggest")).toBe("suggest");
99 expect(normalizeMode("", "suggest")).toBe("suggest");
100 });
101});
102
103describe("resolveEffectiveMode — env kill-switches stay supreme", () => {
104 it("env off forces 'off' regardless of the repo mode", () => {
105 expect(resolveEffectiveMode("auto", false)).toBe("off");
106 expect(resolveEffectiveMode("suggest", false)).toBe("off");
107 expect(resolveEffectiveMode("off", false)).toBe("off");
108 });
109
110 it("env on yields the repo mode unchanged (repo can only narrow)", () => {
111 expect(resolveEffectiveMode("auto", true)).toBe("auto");
112 expect(resolveEffectiveMode("suggest", true)).toBe("suggest");
113 expect(resolveEffectiveMode("off", true)).toBe("off");
114 });
115});
116
117describe("isAutomationOn — 'suggest' and 'auto' both count as on", () => {
118 it("only 'off' is off", () => {
119 expect(isAutomationOn("off")).toBe(false);
120 expect(isAutomationOn("suggest")).toBe(true);
121 expect(isAutomationOn("auto")).toBe(true);
122 });
123});
124
125describe("settingsFromRow", () => {
126 it("null/undefined row → defaults", () => {
127 expect(settingsFromRow(null)).toEqual({ ...AUTOMATION_DEFAULTS });
128 expect(settingsFromRow(undefined)).toEqual({ ...AUTOMATION_DEFAULTS });
129 });
130
131 it("valid row values pass through", () => {
132 const out = settingsFromRow({
133 aiReviewMode: "off",
134 prTriageMode: "off",
135 issueTriageMode: "suggest",
136 autoMergeMode: "suggest",
137 ciAutofixMode: "auto",
138 });
139 expect(out.aiReviewMode).toBe("off");
140 expect(out.prTriageMode).toBe("off");
141 expect(out.issueTriageMode).toBe("suggest");
142 expect(out.autoMergeMode).toBe("suggest");
143 expect(out.ciAutofixMode).toBe("auto");
144 });
145
146 it("corrupt per-field values fall back per-field to the defaults", () => {
147 const out = settingsFromRow({
148 aiReviewMode: "banana",
149 autoMergeMode: "off",
150 });
151 expect(out.aiReviewMode).toBe("suggest"); // default
152 expect(out.autoMergeMode).toBe("off"); // valid value kept
153 expect(out.ciAutofixMode).toBe("suggest"); // missing → default
154 });
155});
156
157// ---------------------------------------------------------------------------
158// Dispatch-site gating — auto-merge (behavioral, via the DI seam)
159// ---------------------------------------------------------------------------
160
161describe("evaluateAutoMerge — per-repo automation gate", () => {
162 const ctx = {
163 pullRequestId: "pr-1",
164 repositoryId: REPO_ID,
165 baseBranch: "main",
166 isDraft: false,
167 authorUserId: "user-1",
168 };
169
170 it("'off' blocks the merge before any DB work", async () => {
171 const { loader, calls } = makeLoader(makeSettings({ autoMergeMode: "off" }));
172 const decision = await evaluateAutoMerge(ctx, {
173 loadAutomationSettings: loader,
174 });
175 expect(decision.merge).toBe(false);
176 expect(decision.reason).toContain("turned off");
177 expect(decision.blocking?.length).toBe(1);
178 expect(calls).toEqual([REPO_ID]);
179 });
180
181 it("'suggest' evaluates to merge:false with a human-handoff reason", async () => {
182 const { loader } = makeLoader(makeSettings({ autoMergeMode: "suggest" }));
183 const decision = await evaluateAutoMerge(ctx, {
184 loadAutomationSettings: loader,
185 });
186 expect(decision.merge).toBe(false);
187 expect(decision.reason).toContain("suggest");
188 expect(decision.reason).toContain("merge left to a human");
189 });
190
191 it("'auto' proceeds past the gate into the K2 evaluation", async () => {
192 const { loader, calls } = makeLoader(makeSettings({ autoMergeMode: "auto" }));
193 // With no DATABASE_URL the downstream matchProtection lookup fails;
194 // the contract here is only that the gate did NOT short-circuit —
195 // i.e. the loader was consulted and the decision (whatever the DB
196 // state yields) is not the automation-settings refusal.
197 let decision: { merge: boolean; reason: string } | null = null;
198 try {
199 decision = await evaluateAutoMerge(ctx, {
200 loadAutomationSettings: loader,
201 });
202 } catch {
203 // Downstream DB failure is acceptable in this DB-less environment.
204 }
205 expect(calls).toEqual([REPO_ID]);
206 if (decision) {
207 expect(decision.reason).not.toContain("automation settings");
208 }
209 });
210});
211
212// ---------------------------------------------------------------------------
213// Dispatch-site gating — source wiring pins for the fire-and-forget paths
214// (same readFileSync technique as repair-flywheel-wiring.test.ts; these
215// functions swallow every error by contract, so source pins are the
216// reliable way to assert the gate exists and sits on the live path).
217// ---------------------------------------------------------------------------
218
219describe("dispatch-site source wiring", () => {
220 const read = (rel: string) =>
221 readFileSync(join(import.meta.dir, rel), "utf8");
222
223 it("ai-review: triggerAiReview consults the per-repo setting and skips on 'off'", () => {
224 const src = read("../lib/ai-review.ts");
225 expect(src).toContain('from "./automation-settings"');
226 expect(src).toContain("options.loadSettings ?? getAutomationSettings");
227 expect(src).toContain('if (automation.aiReviewMode === "off") return;');
228 });
229
230 it("pr-triage: triggerPrTriage consults the per-repo setting and skips on 'off'", () => {
231 const src = read("../lib/pr-triage.ts");
232 expect(src).toContain('from "./automation-settings"');
233 expect(src).toContain('if (automation.prTriageMode === "off") return;');
234 });
235
236 it("issue-triage: triggerIssueTriage consults the per-repo setting and skips on 'off'", () => {
237 const src = read("../lib/issue-triage.ts");
238 expect(src).toContain('from "./automation-settings"');
239 expect(src).toContain('if (automation.issueTriageMode === "off") return;');
240 });
241
242 it("ci-autofix: _runAutofix skips on 'off' and auto-applies on 'auto'", () => {
243 const src = read("../lib/ci-autofix.ts");
244 expect(src).toContain('from "./automation-settings"');
245 expect(src).toContain('if (automation.ciAutofixMode === "off") return;');
246 expect(src).toContain('automation.ciAutofixMode === "auto"');
247 expect(src).toContain("applyAutofix(posted.id, repoRow.ownerId, deps)");
248 });
249
250 it("env guards stay supreme at the triage/review dispatch sites", () => {
251 // The per-repo gate must sit BEHIND the existing env kill-switches so
252 // a repo setting can never widen what the environment allows.
253 for (const rel of ["../lib/pr-triage.ts", "../lib/issue-triage.ts"]) {
254 const src = read(rel);
255 const envIdx = src.indexOf("if (!isAiAvailable()) return;");
256 const gateIdx = src.indexOf("options.loadSettings ?? getAutomationSettings");
257 expect(envIdx).toBeGreaterThan(-1);
258 expect(gateIdx).toBeGreaterThan(envIdx);
259 }
260 const review = read("../lib/ai-review.ts");
261 const envIdx = review.indexOf("if (!isAiReviewEnabled()) return;");
262 const gateIdx = review.indexOf("options.loadSettings ?? getAutomationSettings");
263 expect(envIdx).toBeGreaterThan(-1);
264 expect(gateIdx).toBeGreaterThan(envIdx);
265 });
266});
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
3434import shipAgentRoutes from "./routes/ship-agent";
3535import commentModerationRoutes from "./routes/comment-moderation";
3636import repoSettings from "./routes/repo-settings";
37import automationSettingsRoutes from "./routes/automation-settings";
3738import collaboratorRoutes from "./routes/collaborators";
3839import teamCollaboratorRoutes from "./routes/team-collaborators";
3940import invitesRoutes from "./routes/invites";
493494
494495// Repo settings (description, visibility, delete)
495496app.route("/", repoSettings);
497// Per-repo automation settings (off / suggest / auto) — migration 0106
498app.route("/", automationSettingsRoutes);
496499
497500// Repo collaborators (add/list/remove)
498501app.route("/", collaboratorRoutes);
Modifiedsrc/db/schema.ts+34−0View fileUnifiedSplit
46494649);
46504650
46514651export type WorkspaceJob = typeof workspaceJobs.$inferSelect;
4652
4653// ---------------------------------------------------------------------------
4654// Migration 0106 — Per-repo automation settings (off / suggest / auto).
4655// One row per repository; absence of a row means the defaults in
4656// src/lib/automation-settings.ts, which match pre-0106 behavior exactly.
4657// Env kill-switches stay supreme over anything stored here.
4658// ---------------------------------------------------------------------------
4659export const repoAutomationSettings = pgTable(
4660 "repo_automation_settings",
4661 {
4662 id: uuid("id").primaryKey().defaultRandom(),
4663 repositoryId: uuid("repository_id")
4664 .notNull()
4665 .unique()
4666 .references(() => repositories.id, { onDelete: "cascade" }),
4667 /** 'off' | 'suggest' — AI code review on PR open ('auto' = 'suggest'). */
4668 aiReviewMode: text("ai_review_mode").notNull().default("suggest"),
4669 /** 'off' | 'suggest' — AI triage comment on PR open. */
4670 prTriageMode: text("pr_triage_mode").notNull().default("suggest"),
4671 /** 'off' | 'suggest' — AI triage comment on issue create. */
4672 issueTriageMode: text("issue_triage_mode").notNull().default("suggest"),
4673 /** 'off' | 'suggest' | 'auto' — AI-gated auto-merge evaluation. */
4674 autoMergeMode: text("auto_merge_mode").notNull().default("auto"),
4675 /** 'off' | 'suggest' | 'auto' — CI auto-fix on failed gate runs. */
4676 ciAutofixMode: text("ci_autofix_mode").notNull().default("suggest"),
4677 createdAt: timestamp("created_at").defaultNow(),
4678 updatedAt: timestamp("updated_at").defaultNow(),
4679 },
4680 (table) => [
4681 index("idx_repo_automation_settings_repo").on(table.repositoryId),
4682 ]
4683);
4684
4685export type RepoAutomationSettings = typeof repoAutomationSettings.$inferSelect;
Modifiedsrc/lib/ai-review.ts+13−1View fileUnifiedSplit
1919} from "./ai-review-trio";
2020import { assertAiQuota, AiQuotaExceededError } from "./billing";
2121import { getBotUserIdOrFallback } from "./bot-user";
22import {
23 getAutomationSettings,
24 type AutomationSettingsLoader,
25} from "./automation-settings";
2226
2327interface ReviewComment {
2428 filePath: string;
282286 body: string,
283287 baseBranch: string,
284288 headBranch: string,
285 options: { force?: boolean } = {}
289 options: { force?: boolean; loadSettings?: AutomationSettingsLoader } = {}
286290): Promise<void> {
287291 try {
288292 if (!isAiReviewEnabled()) return;
306310 .limit(1);
307311 if (!pr) return;
308312
313 // Per-repo automation gate — 'off' skips AI review entirely. The loader
314 // fails open to the defaults ('suggest' = current behavior), so a broken
315 // settings lookup can never disable reviews.
316 const automation = await (options.loadSettings ?? getAutomationSettings)(
317 pr.repositoryId
318 );
319 if (automation.aiReviewMode === "off") return;
320
309321 // Hard quota gate — post a comment and bail if the user's AI budget is
310322 // exhausted. This runs after loading the PR so we have authorId for the
311323 // comment insert.
Modifiedsrc/lib/auto-merge.ts+20−0View fileUnifiedSplit
5151import { getRepoPath } from "../git/repository";
5252import { getLatestCachedPrRisk } from "./pr-risk";
5353import { performMerge, type PerformMergeResult } from "./pr-merge";
54import {
55 getAutomationSettings,
56 type AutomationSettingsLoader,
57} from "./automation-settings";
5458
5559// ---------------------------------------------------------------------------
5660// Public types
8589 repoName?: string;
8690 /** Head branch for the diff-size check. Required when caps are set. */
8791 headBranch?: string;
92 /** Test-only injection of the per-repo automation settings loader. */
93 loadAutomationSettings?: AutomationSettingsLoader;
8894}
8995
9096// ---------------------------------------------------------------------------
303309 ctx: AutoMergeContext,
304310 opts: AutoMergeOptions = {}
305311): Promise<AutoMergeDecision> {
312 // 0. Per-repo automation gate. 'auto' (the fail-open default — matching
313 // pre-0106 behavior) proceeds to the normal K2 evaluation; 'suggest'
314 // records the decision trail but never merges; 'off' skips entirely.
315 const automation = await (opts.loadAutomationSettings ?? getAutomationSettings)(
316 ctx.repositoryId
317 );
318 if (automation.autoMergeMode !== "auto") {
319 const reason =
320 automation.autoMergeMode === "off"
321 ? "Auto-merge is turned off in this repository's automation settings."
322 : "Auto-merge is set to 'suggest' in this repository's automation settings — evaluation recorded, merge left to a human.";
323 return { merge: false, reason, blocking: [reason] };
324 }
325
306326 // 1. Match the protection rule. matchProtection returns the most
307327 // specific rule, or null when none configured.
308328 const rule = await matchProtection(ctx.repositoryId, ctx.baseBranch);
Addedsrc/lib/automation-settings.ts+178−0View fileUnifiedSplit
1/**
2 * Per-repo automation settings — the single read/write surface for the
3 * "Automation" settings page (`/:owner/:repo/settings/automation`) and for
4 * every automation dispatch site (AI review, PR/issue triage, auto-merge,
5 * CI autofix).
6 *
7 * Contract:
8 *
9 * - One `repo_automation_settings` row per repository (migration 0106).
10 * No row → `AUTOMATION_DEFAULTS`, which match pre-0106 behavior exactly,
11 * so a repo that never touches the page behaves as it always did.
12 * - `getAutomationSettings` FAILS OPEN: any DB error returns the defaults
13 * so a broken lookup can never change platform behavior.
14 * - Env kill-switches stay supreme. A feature disabled at the environment
15 * level (missing ANTHROPIC_API_KEY, AI_LOOP_ENABLED unset, …) is off
16 * regardless of the stored mode — see `resolveEffectiveMode`. Dispatch
17 * sites keep their existing env guards; this module only ever *narrows*
18 * what runs, never widens it.
19 *
20 * Mode semantics per feature (see drizzle/0106 for the long-form docs):
21 *
22 * aiReviewMode 'off' | 'suggest' (default 'suggest')
23 * prTriageMode 'off' | 'suggest' (default 'suggest')
24 * issueTriageMode 'off' | 'suggest' (default 'suggest')
25 * autoMergeMode 'off' | 'suggest' | 'auto' (default 'auto')
26 * ciAutofixMode 'off' | 'suggest' | 'auto' (default 'suggest')
27 *
28 * Where only on/off is meaningful, 'auto' is treated the same as 'suggest'
29 * (i.e. "on") — `isAutomationOn` encodes that rule.
30 */
31
32import { eq } from "drizzle-orm";
33import { db } from "../db";
34import { repoAutomationSettings } from "../db/schema";
35
36// ---------------------------------------------------------------------------
37// Types + defaults
38// ---------------------------------------------------------------------------
39
40export type AutomationMode = "off" | "suggest" | "auto";
41
42export interface AutomationSettings {
43 aiReviewMode: AutomationMode;
44 prTriageMode: AutomationMode;
45 issueTriageMode: AutomationMode;
46 autoMergeMode: AutomationMode;
47 ciAutofixMode: AutomationMode;
48}
49
50/** Loader signature — the DI seam dispatch sites accept for tests. */
51export type AutomationSettingsLoader = (
52 repositoryId: string
53) => Promise<AutomationSettings>;
54
55/**
56 * Defaults = pre-0106 platform behavior. Auto-merge defaults to 'auto'
57 * because the K2/K3 path already merges automatically (still default-deny
58 * per branch via branch_protection.enable_auto_merge); everything else
59 * defaults to 'suggest' because those features only ever posted advisory
60 * comments.
61 */
62export const AUTOMATION_DEFAULTS: Readonly<AutomationSettings> = Object.freeze({
63 aiReviewMode: "suggest",
64 prTriageMode: "suggest",
65 issueTriageMode: "suggest",
66 autoMergeMode: "auto",
67 ciAutofixMode: "suggest",
68});
69
70// ---------------------------------------------------------------------------
71// Pure mode-resolution helpers (unit-testable, no DB)
72// ---------------------------------------------------------------------------
73
74/** Parse an untrusted value (form input, stale DB row) into a mode. */
75export function normalizeMode(
76 value: unknown,
77 fallback: AutomationMode
78): AutomationMode {
79 return value === "off" || value === "suggest" || value === "auto"
80 ? value
81 : fallback;
82}
83
84/**
85 * Env-supremacy rule: when the environment kill-switch for a feature is
86 * off, the effective mode is 'off' no matter what the repo row says. The
87 * repo setting can only narrow further (e.g. env on + repo 'off' → 'off').
88 */
89export function resolveEffectiveMode(
90 repoMode: AutomationMode,
91 envEnabled: boolean
92): AutomationMode {
93 if (!envEnabled) return "off";
94 return repoMode;
95}
96
97/** "Is this feature on at all?" — 'suggest' and 'auto' both count as on. */
98export function isAutomationOn(mode: AutomationMode): boolean {
99 return mode !== "off";
100}
101
102/**
103 * Coerce a raw row (or anything row-shaped) into a fully-valid settings
104 * object, falling back per-field to the defaults. Exported for tests.
105 */
106export function settingsFromRow(
107 row: Partial<Record<keyof AutomationSettings, unknown>> | null | undefined
108): AutomationSettings {
109 if (!row) return { ...AUTOMATION_DEFAULTS };
110 return {
111 aiReviewMode: normalizeMode(row.aiReviewMode, AUTOMATION_DEFAULTS.aiReviewMode),
112 prTriageMode: normalizeMode(row.prTriageMode, AUTOMATION_DEFAULTS.prTriageMode),
113 issueTriageMode: normalizeMode(
114 row.issueTriageMode,
115 AUTOMATION_DEFAULTS.issueTriageMode
116 ),
117 autoMergeMode: normalizeMode(row.autoMergeMode, AUTOMATION_DEFAULTS.autoMergeMode),
118 ciAutofixMode: normalizeMode(row.ciAutofixMode, AUTOMATION_DEFAULTS.ciAutofixMode),
119 };
120}
121
122// ---------------------------------------------------------------------------
123// DB-backed loader + upsert
124// ---------------------------------------------------------------------------
125
126/**
127 * Load the automation settings for a repository. No row → defaults.
128 * FAILS OPEN: any DB error also returns the defaults (logged once at warn)
129 * so a broken settings lookup never alters current platform behavior.
130 */
131export async function getAutomationSettings(
132 repositoryId: string
133): Promise<AutomationSettings> {
134 try {
135 const [row] = await db
136 .select()
137 .from(repoAutomationSettings)
138 .where(eq(repoAutomationSettings.repositoryId, repositoryId))
139 .limit(1);
140 return settingsFromRow(row ?? null);
141 } catch (err) {
142 console.warn(
143 "[automation-settings] lookup failed (falling back to defaults):",
144 err instanceof Error ? err.message : err
145 );
146 return { ...AUTOMATION_DEFAULTS };
147 }
148}
149
150/**
151 * Create or update the settings row for a repository. Partial patches are
152 * merged over the current effective settings so a form that only posts one
153 * field can't reset the others. Throws on DB failure — callers (the
154 * settings route) surface the error to the user.
155 */
156export async function upsertAutomationSettings(
157 repositoryId: string,
158 patch: Partial<AutomationSettings>
159): Promise<AutomationSettings> {
160 const current = await getAutomationSettings(repositoryId);
161 const next: AutomationSettings = {
162 aiReviewMode: normalizeMode(patch.aiReviewMode, current.aiReviewMode),
163 prTriageMode: normalizeMode(patch.prTriageMode, current.prTriageMode),
164 issueTriageMode: normalizeMode(patch.issueTriageMode, current.issueTriageMode),
165 autoMergeMode: normalizeMode(patch.autoMergeMode, current.autoMergeMode),
166 ciAutofixMode: normalizeMode(patch.ciAutofixMode, current.ciAutofixMode),
167 };
168
169 await db
170 .insert(repoAutomationSettings)
171 .values({ repositoryId, ...next })
172 .onConflictDoUpdate({
173 target: repoAutomationSettings.repositoryId,
174 set: { ...next, updatedAt: new Date() },
175 });
176
177 return next;
178}
Modifiedsrc/lib/ci-autofix.ts+42−6View fileUnifiedSplit
5151 extractText,
5252 parseJsonResponse,
5353} from "./ai-client";
54import {
55 getAutomationSettings,
56 type AutomationSettingsLoader,
57} from "./automation-settings";
5458
5559// ---------------------------------------------------------------------------
5660// Types
8286 recordRepair?: typeof recordRepair;
8387 updateOutcome?: typeof updateOutcome;
8488 aiAvailable?: () => boolean;
89 /** Inject the per-repo automation settings loader (tests). */
90 loadAutomationSettings?: AutomationSettingsLoader;
8591}
8692
8793/** The fix triggerCiAutofix decided to post, plus its flywheel bookkeeping. */
412418 id: repositories.id,
413419 name: repositories.name,
414420 diskPath: repositories.diskPath,
421 ownerId: repositories.ownerId,
415422 ownerUsername: users.username,
416423 })
417424 .from(repositories)
421428
422429 if (!repoRow) return;
423430
431 // Per-repo automation gate — 'off' skips CI autofix entirely; 'suggest'
432 // (the fail-open default) posts the patch comment as before; 'auto'
433 // additionally applies the patch onto a fix/ branch below.
434 const automation = await (deps.loadAutomationSettings ?? getAutomationSettings)(
435 repoRow.id
436 );
437 if (automation.ciAutofixMode === "off") return;
438
424439 // 4. Check idempotency — skip if already posted for this gateRunId
425440 const idempotencyMarker = `<!-- gluecron:ci-autofix:run:${gateRunId} -->`;
426441 const existing = await db
526541 const botAuthorId = await getBotUserIdOrFallback(repoRow.id);
527542 if (!botAuthorId) return;
528543
529 await db.insert(prComments).values({
530 pullRequestId: gateRun.pullRequestId,
531 authorId: botAuthorId,
532 body: commentBody,
533 isAiReview: true,
534 });
544 const [posted] = await db
545 .insert(prComments)
546 .values({
547 pullRequestId: gateRun.pullRequestId,
548 authorId: botAuthorId,
549 body: commentBody,
550 isAiReview: true,
551 })
552 .returning({ id: prComments.id });
553
554 // 'auto' mode — also apply the patch onto a fix/ branch via the same
555 // path the Apply Fix button drives. Best-effort: an apply failure (it
556 // also settles the flywheel entry as 'failed') leaves the suggest-mode
557 // comment in place for a human to act on.
558 if (automation.ciAutofixMode === "auto" && posted) {
559 try {
560 const { branchName } = await applyAutofix(posted.id, repoRow.ownerId, deps);
561 console.log(
562 `[ci-autofix] auto-applied patch from comment ${posted.id} onto ${branchName}`
563 );
564 } catch (err) {
565 console.warn(
566 "[ci-autofix] auto-apply failed (patch comment still posted):",
567 err instanceof Error ? err.message : err
568 );
569 }
570 }
535571}
536572
537573function buildAutofixComment(
Modifiedsrc/lib/issue-triage.ts+11−1View fileUnifiedSplit
2626} from "../db/schema";
2727import { triageIssue, type IssueTriage } from "./ai-generators";
2828import { isAiAvailable } from "./ai-client";
29import {
30 getAutomationSettings,
31 type AutomationSettingsLoader,
32} from "./automation-settings";
2933
3034export const ISSUE_TRIAGE_MARKER = "<!-- gluecron-issue-triage:summary -->";
3135
135139
136140export async function triggerIssueTriage(
137141 input: IssueTriageInput,
138 options: { force?: boolean } = {}
142 options: { force?: boolean; loadSettings?: AutomationSettingsLoader } = {}
139143): Promise<void> {
140144 try {
141145 if (process.env.DEBUG_ISSUE_TRIAGE === "1") {
147151 );
148152 }
149153 if (!isAiAvailable()) return;
154 // Per-repo automation gate — 'off' skips triage; loader fails open to
155 // the default ('suggest' = current behavior). Env stays supreme above.
156 const automation = await (options.loadSettings ?? getAutomationSettings)(
157 input.repositoryId
158 );
159 if (automation.issueTriageMode === "off") return;
150160 if (!options.force && (await alreadyTriaged(input.issueId))) return;
151161
152162 const [availableLabels, recent] = await Promise.all([
Modifiedsrc/lib/pr-triage.ts+14−1View fileUnifiedSplit
2727import { getRepoPath } from "../git/repository";
2828import { triagePullRequest, type PrTriage } from "./ai-generators";
2929import { isAiAvailable } from "./ai-client";
30import {
31 getAutomationSettings,
32 type AutomationSettingsLoader,
33} from "./automation-settings";
3034
3135export const PR_TRIAGE_MARKER = "<!-- gluecron-pr-triage:summary -->";
3236
196200 ].join("\n");
197201}
198202
199export async function triggerPrTriage(input: PrTriageInput): Promise<void> {
203export async function triggerPrTriage(
204 input: PrTriageInput,
205 options: { loadSettings?: AutomationSettingsLoader } = {}
206): Promise<void> {
200207 try {
201208 if (process.env.DEBUG_PR_TRIAGE === "1") {
202209 console.log(
207214 );
208215 }
209216 if (!isAiAvailable()) return;
217 // Per-repo automation gate — 'off' skips triage; loader fails open to
218 // the default ('suggest' = current behavior). Env stays supreme above.
219 const automation = await (options.loadSettings ?? getAutomationSettings)(
220 input.repositoryId
221 );
222 if (automation.prTriageMode === "off") return;
210223 if (await alreadyTriaged(input.prId)) return;
211224
212225 const [diffSummary, availableLabels, candidateReviewers] = await Promise.all([
Addedsrc/routes/automation-settings.tsx+485−0View fileUnifiedSplit
1/**
2 * Per-repo Automation settings — ONE page where a developer sees every
3 * automation on the platform and flips each between off / suggest (manual)
4 * / automatic, wherever those modes exist today.
5 *
6 * GET /:owner/:repo/settings/automation — the control table
7 * POST /:owner/:repo/settings/automation — save modes
8 *
9 * Admin-gated (same requireAuth + requireRepoAccess("admin") pattern as
10 * src/routes/repo-settings.tsx).
11 *
12 * Storage:
13 * - The five mode-controlled automations (AI review, PR triage, issue
14 * triage, auto-merge, CI autofix) live in `repo_automation_settings`
15 * (migration 0106) via src/lib/automation-settings.ts.
16 * - AI test generation and the dependency updater keep their existing
17 * homes on the repositories row (`auto_generate_tests`,
18 * `dep_updater_enabled`) — this page is just a second door to the same
19 * columns, so the older toggles stay in sync.
20 *
21 * Env kill-switches stay supreme: a feature disabled at the environment
22 * level is off regardless of what is selected here. The page says so.
23 */
24
25import { Hono } from "hono";
26import { eq } from "drizzle-orm";
27import { db } from "../db";
28import { repositories } from "../db/schema";
29import type { Repository } from "../db/schema";
30import { Layout } from "../views/layout";
31import { RepoHeader } from "../views/components";
32import { softAuth, requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import { requireRepoAccess } from "../middleware/repo-access";
35import {
36 getAutomationSettings,
37 upsertAutomationSettings,
38 normalizeMode,
39 type AutomationMode,
40 type AutomationSettings,
41} from "../lib/automation-settings";
42
43const automationSettings = new Hono<AuthEnv>();
44
45automationSettings.use("*", softAuth);
46
47// Scoped CSS — every class prefixed `.automation-` so styles cannot bleed.
48// Mirrors the section-card system in repo-settings.tsx.
49const automationStyles = `
50 .automation-container { max-width: 1080px; margin: 0 auto; padding: 0 var(--space-3) var(--space-8); }
51 .automation-hero {
52 position: relative;
53 margin: var(--space-5) 0;
54 padding: var(--space-5) var(--space-6);
55 background: var(--bg-elevated);
56 border: 1px solid var(--border);
57 border-radius: 16px;
58 overflow: hidden;
59 }
60 .automation-hero::before {
61 content: '';
62 position: absolute;
63 top: 0; left: 0; right: 0;
64 height: 2px;
65 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
66 opacity: 0.7;
67 pointer-events: none;
68 }
69 .automation-hero-eyebrow {
70 font-size: 11px;
71 font-weight: 600;
72 letter-spacing: 0.08em;
73 text-transform: uppercase;
74 color: var(--accent);
75 margin-bottom: 6px;
76 }
77 .automation-hero-title {
78 font-family: var(--font-display);
79 font-size: clamp(26px, 4vw, 36px);
80 font-weight: 800;
81 letter-spacing: -0.028em;
82 line-height: 1.05;
83 margin: 0 0 var(--space-2);
84 color: var(--text-strong);
85 }
86 .automation-hero-sub {
87 font-size: 14.5px;
88 color: var(--text-muted);
89 margin: 0;
90 line-height: 1.55;
91 max-width: 680px;
92 }
93 .automation-hero-sub code {
94 font-family: var(--font-mono);
95 font-size: 12.5px;
96 background: var(--bg-tertiary);
97 padding: 1px 5px;
98 border-radius: 4px;
99 }
100 .automation-banner {
101 display: flex;
102 align-items: center;
103 gap: 10px;
104 padding: 12px 16px;
105 border-radius: 12px;
106 font-size: 13.5px;
107 margin-bottom: var(--space-4);
108 line-height: 1.5;
109 }
110 .automation-banner-success {
111 background: rgba(52,211,153,0.08);
112 color: #6ee7b7;
113 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
114 }
115 .automation-banner-error {
116 background: rgba(248,113,113,0.08);
117 color: #fca5a5;
118 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
119 }
120 .automation-card {
121 background: var(--bg-elevated);
122 border: 1px solid var(--border);
123 border-radius: 14px;
124 overflow: hidden;
125 margin-bottom: var(--space-5);
126 }
127 .automation-table { width: 100%; border-collapse: collapse; }
128 .automation-table th {
129 text-align: left;
130 font-size: 11px;
131 font-weight: 600;
132 letter-spacing: 0.08em;
133 text-transform: uppercase;
134 color: var(--text-muted);
135 padding: 12px var(--space-5);
136 border-bottom: 1px solid var(--border);
137 }
138 .automation-table td {
139 padding: 14px var(--space-5);
140 border-bottom: 1px solid var(--border);
141 vertical-align: top;
142 }
143 .automation-table tr:last-child td { border-bottom: none; }
144 .automation-feature-name {
145 font-weight: 600;
146 font-size: 13.5px;
147 color: var(--text-strong);
148 white-space: nowrap;
149 }
150 .automation-feature-desc {
151 font-size: 12.5px;
152 color: var(--text-muted);
153 line-height: 1.5;
154 max-width: 520px;
155 }
156 .automation-feature-desc code {
157 font-family: var(--font-mono);
158 font-size: 11.5px;
159 background: var(--bg-tertiary);
160 padding: 1px 4px;
161 border-radius: 4px;
162 }
163 .automation-select {
164 background: var(--bg-tertiary);
165 color: var(--text-strong);
166 border: 1px solid var(--border);
167 border-radius: 8px;
168 padding: 6px 10px;
169 font-size: 13px;
170 min-width: 150px;
171 }
172 .automation-env-pill {
173 display: inline-block;
174 font-size: 11px;
175 font-weight: 600;
176 padding: 2px 8px;
177 border-radius: 9999px;
178 background: rgba(140,109,255,0.12);
179 color: #b69dff;
180 white-space: nowrap;
181 }
182 .automation-foot {
183 padding: var(--space-3) var(--space-5);
184 border-top: 1px solid var(--border);
185 background: rgba(255,255,255,0.012);
186 display: flex;
187 justify-content: flex-end;
188 align-items: center;
189 gap: var(--space-3);
190 }
191 .automation-foot-hint {
192 margin-right: auto;
193 font-size: 12.5px;
194 color: var(--text-muted);
195 }
196 .automation-cta {
197 display: inline-flex;
198 align-items: center;
199 gap: 7px;
200 padding: 9px 18px;
201 background: linear-gradient(135deg, #8c6dff, #6c63ff);
202 color: #fff;
203 border: none;
204 border-radius: 9px;
205 font-size: 13.5px;
206 font-weight: 600;
207 cursor: pointer;
208 }
209 .automation-cta:hover { filter: brightness(1.08); }
210`;
211
212/** One row of the control table — a three-way (or two-way) mode selector. */
213function ModeSelect(props: {
214 name: string;
215 value: AutomationMode;
216 modes: AutomationMode[];
217 labels?: Partial<Record<AutomationMode, string>>;
218}) {
219 const defaultLabels: Record<AutomationMode, string> = {
220 off: "Off",
221 suggest: "Suggest (manual)",
222 auto: "Automatic",
223 };
224 return (
225 <select class="automation-select" name={props.name} aria-label={props.name}>
226 {props.modes.map((m) => (
227 <option value={m} selected={m === props.value}>
228 {props.labels?.[m] ?? defaultLabels[m]}
229 </option>
230 ))}
231 </select>
232 );
233}
234
235automationSettings.get(
236 "/:owner/:repo/settings/automation",
237 requireAuth,
238 requireRepoAccess("admin"),
239 async (c) => {
240 const { owner: ownerName, repo: repoName } = c.req.param();
241 const user = c.get("user")!;
242 const repo = c.get("repository" as never) as Repository;
243 const success = c.req.query("success");
244 const error = c.req.query("error");
245
246 const settings = await getAutomationSettings(repo.id);
247
248 return c.html(
249 <Layout title={`Automation — ${ownerName}/${repoName}`} user={user}>
250 <RepoHeader owner={ownerName} repo={repoName} />
251 <style dangerouslySetInnerHTML={{ __html: automationStyles }} />
252 <div class="automation-container">
253 <div class="automation-hero">
254 <div class="automation-hero-eyebrow">Repository settings</div>
255 <h1 class="automation-hero-title">Automation</h1>
256 <p class="automation-hero-sub">
257 Every automation on this repository in one place. <strong>Off</strong>{" "}
258 disables a feature, <strong>Suggest</strong> posts advisory
259 comments and leaves the action to you, <strong>Automatic</strong>{" "}
260 lets Gluecron act on its own. Server-level kill-switches (e.g. a
261 missing <code>ANTHROPIC_API_KEY</code>) always win — a feature
262 disabled in the environment stays off no matter what you pick
263 here.
264 </p>
265 </div>
266
267 {success && (
268 <div class="automation-banner automation-banner-success">
269 {decodeURIComponent(success)}
270 </div>
271 )}
272 {error && (
273 <div class="automation-banner automation-banner-error">
274 {decodeURIComponent(error)}
275 </div>
276 )}
277
278 <form method="post" action={`/${ownerName}/${repoName}/settings/automation`}>
279 <div class="automation-card">
280 <table class="automation-table">
281 <thead>
282 <tr>
283 <th>Automation</th>
284 <th>What it does</th>
285 <th>Mode</th>
286 </tr>
287 </thead>
288 <tbody>
289 <tr>
290 <td class="automation-feature-name">AI code review</td>
291 <td class="automation-feature-desc">
292 Claude reviews every non-draft PR diff on open and posts
293 a summary plus inline findings. Review comments are
294 always advisory.
295 </td>
296 <td>
297 <ModeSelect
298 name="ai_review_mode"
299 value={settings.aiReviewMode}
300 modes={["off", "suggest"]}
301 labels={{ suggest: "Suggest (on)" }}
302 />
303 </td>
304 </tr>
305 <tr>
306 <td class="automation-feature-name">PR triage</td>
307 <td class="automation-feature-desc">
308 Suggests labels, reviewers, priority, and a risk area as
309 a comment when a PR opens. Nothing is applied for you.
310 </td>
311 <td>
312 <ModeSelect
313 name="pr_triage_mode"
314 value={settings.prTriageMode}
315 modes={["off", "suggest"]}
316 labels={{ suggest: "Suggest (on)" }}
317 />
318 </td>
319 </tr>
320 <tr>
321 <td class="automation-feature-name">Issue triage</td>
322 <td class="automation-feature-desc">
323 Suggests labels, priority, and possible duplicates as a
324 comment when an issue is created.
325 </td>
326 <td>
327 <ModeSelect
328 name="issue_triage_mode"
329 value={settings.issueTriageMode}
330 modes={["off", "suggest"]}
331 labels={{ suggest: "Suggest (on)" }}
332 />
333 </td>
334 </tr>
335 <tr>
336 <td class="automation-feature-name">Auto-merge</td>
337 <td class="automation-feature-desc">
338 Merges a PR once every branch-protection gate is green.
339 Still default-deny per branch — a rule with{" "}
340 <a href={`/${ownerName}/${repoName}/settings#branch-protection`}>
341 auto-merge enabled
342 </a>{" "}
343 must match the base branch. <em>Suggest</em> evaluates
344 and records the decision but leaves the Merge click to a
345 human.
346 </td>
347 <td>
348 <ModeSelect
349 name="auto_merge_mode"
350 value={settings.autoMergeMode}
351 modes={["off", "suggest", "auto"]}
352 />
353 </td>
354 </tr>
355 <tr>
356 <td class="automation-feature-name">CI auto-fix</td>
357 <td class="automation-feature-desc">
358 When a gate run fails on a PR, posts a ready-to-apply
359 patch (repair-cache first, then Claude). <em>Suggest</em>{" "}
360 stops at the comment with an Apply Fix button;{" "}
361 <em>Automatic</em> also applies the patch onto a{" "}
362 <code>fix/</code> branch.
363 </td>
364 <td>
365 <ModeSelect
366 name="ci_autofix_mode"
367 value={settings.ciAutofixMode}
368 modes={["off", "suggest", "auto"]}
369 />
370 </td>
371 </tr>
372 <tr>
373 <td class="automation-feature-name">AI test generation</td>
374 <td class="automation-feature-desc">
375 Writes tests for new code when a PR opens and commits
376 them onto the same branch. Acts on its own once enabled.
377 </td>
378 <td>
379 <ModeSelect
380 name="auto_generate_tests"
381 value={repo.autoGenerateTests ? "auto" : "off"}
382 modes={["off", "auto"]}
383 />
384 </td>
385 </tr>
386 <tr>
387 <td class="automation-feature-name">Dependency updates</td>
388 <td class="automation-feature-desc">
389 Daily patch/minor dependency bumps; auto-merges when the
390 gate passes, opens a PR with an AI migration guide when
391 it fails. Also needs <code>DEP_UPDATER_ENABLED=1</code>{" "}
392 on the server.{" "}
393 <a href={`/${ownerName}/${repoName}/settings/dep-updater`}>
394 Run history →
395 </a>
396 </td>
397 <td>
398 <ModeSelect
399 name="dep_updater_enabled"
400 value={
401 (repo as { depUpdaterEnabled?: boolean })
402 .depUpdaterEnabled
403 ? "auto"
404 : "off"
405 }
406 modes={["off", "auto"]}
407 />
408 </td>
409 </tr>
410 <tr>
411 <td class="automation-feature-name">AI loop (issue → PR → merge)</td>
412 <td class="automation-feature-desc">
413 The fully-autonomous build loop. Controlled by the
414 server-level <code>AI_LOOP_ENABLED=1</code> flag — there
415 is no per-repo dial for it today.
416 </td>
417 <td>
418 <span class="automation-env-pill">env-controlled</span>
419 </td>
420 </tr>
421 </tbody>
422 </table>
423 <div class="automation-foot">
424 <span class="automation-foot-hint">
425 Defaults match prior behavior — saving without changes
426 changes nothing.
427 </span>
428 <button type="submit" class="automation-cta">
429 Save automation settings <span></span>
430 </button>
431 </div>
432 </div>
433 </form>
434 </div>
435 </Layout>
436 );
437 }
438);
439
440automationSettings.post(
441 "/:owner/:repo/settings/automation",
442 requireAuth,
443 requireRepoAccess("admin"),
444 async (c) => {
445 const { owner: ownerName, repo: repoName } = c.req.param();
446 const repo = c.get("repository" as never) as Repository;
447 const body = await c.req.parseBody();
448 const base = `/${ownerName}/${repoName}/settings/automation`;
449
450 try {
451 await upsertAutomationSettings(repo.id, {
452 aiReviewMode: normalizeMode(body["ai_review_mode"], "suggest"),
453 prTriageMode: normalizeMode(body["pr_triage_mode"], "suggest"),
454 issueTriageMode: normalizeMode(body["issue_triage_mode"], "suggest"),
455 autoMergeMode: normalizeMode(body["auto_merge_mode"], "auto"),
456 ciAutofixMode: normalizeMode(body["ci_autofix_mode"], "suggest"),
457 });
458
459 // The two automations that already lived on the repositories row keep
460 // their existing storage so the older settings sections stay in sync.
461 await db
462 .update(repositories)
463 .set({
464 autoGenerateTests: body["auto_generate_tests"] === "auto",
465 depUpdaterEnabled: body["dep_updater_enabled"] === "auto",
466 updatedAt: new Date(),
467 })
468 .where(eq(repositories.id, repo.id));
469 } catch (err) {
470 console.error(
471 "[automation-settings] save failed:",
472 err instanceof Error ? err.message : err
473 );
474 return c.redirect(
475 `${base}?error=${encodeURIComponent("Could not save automation settings. Please try again.")}`
476 );
477 }
478
479 return c.redirect(
480 `${base}?success=${encodeURIComponent("Automation settings saved.")}`
481 );
482 }
483);
484
485export default automationSettings;
Modifiedsrc/routes/marketing.tsx+34−34View fileUnifiedSplit
318318 <h3 class="mkt-teaser-title">Green by default</h3>
319319 <p class="mkt-teaser-desc">
320320 Branch protection, secret scanning, required checks, merge
321 queue — pre-wired on every new repo. Nothing broken ships.
321 queue — pre-wired on every new repo, so problems are caught
322 before they ship.
322323 </p>
323324 </div>
324325 <div class="mkt-teaser-card">
377378
378379 <FeatureCategory
379380 eyebrow="Quality gate"
380 title="Nothing broken ever reaches production."
381 title="Catch problems before they reach production."
381382 items={[
382383 {
383384 title: "GateTest integration",
587588 About
588589 </div>
589590 <h1 class="display mkt-hero-title">
590 We're building the platform{" "}
591 <span class="mkt-grad">software writes itself on.</span>
591 We're building the git platform{" "}
592 <span class="mkt-grad">for AI-assisted teams.</span>
592593 </h1>
593594 <p class="mkt-hero-sub">
594 Most code in 2026 is written by AI. Most reviews are too. The
595 platforms hosting that code were built for a previous era.
596 Gluecron is built for this one.
595 More and more code is written — and reviewed — with AI assistance.
596 The platforms hosting that code were designed for an earlier
597 workflow. Gluecron is built for this one.
597598 </p>
598599 </div>
599600 </header>
612613 Everything is green by default. Every new repo auto-configures
613614 gates, branch protection, labels, CODEOWNERS, and a welcome issue.
614615 Users opt out per feature. Defaults are maximum-green so
615 <strong> nothing broken ever reaches production, the website,
616 or the customer.</strong>
616 <strong> broken changes are caught before they reach production
617 — or your customers.</strong>
617618 </p>
618619 </div>
619620 </section>
796797 position: absolute;
797798 top: 0; left: 0; right: 0;
798799 height: 2px;
799 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
800 opacity: 0.78;
800 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.55) 50%, transparent 100%);
801 opacity: 0.6;
801802 pointer-events: none;
802803 z-index: 2;
803804 }
807808 position: absolute;
808809 inset: -28% -10% auto auto;
809810 width: 520px; height: 520px;
810 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
811 background: radial-gradient(circle, rgba(91,110,232,0.08), transparent 70%);
811812 filter: blur(80px);
812 opacity: 0.75;
813 opacity: 0.6;
813814 pointer-events: none;
814815 z-index: 0;
815816 }
839840 justify-content: center;
840841 width: 18px; height: 18px;
841842 border-radius: 6px;
842 background: rgba(140,109,255,0.14);
843 color: #b69dff;
844 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
843 background: rgba(91,110,232,0.12);
844 color: var(--text-link);
845 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
845846 }
846847 .mkt-page-pricing .mkt-grad,
847848 .mkt-page-features .mkt-grad,
848849 .mkt-page-about .mkt-grad {
849 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
850 background-image: linear-gradient(135deg, var(--accent-hover) 0%, var(--accent) 100%);
850851 -webkit-background-clip: text;
851852 background-clip: text;
852853 -webkit-text-fill-color: transparent;
893894 inset: 0;
894895 z-index: -1;
895896 background:
896 radial-gradient(60% 100% at 50% 0%, rgba(140,109,255,0.16), transparent 65%),
897 radial-gradient(40% 80% at 80% 100%, rgba(54,197,214,0.10), transparent 65%);
897 radial-gradient(60% 100% at 50% 0%, rgba(91,110,232,0.08), transparent 65%);
898898 }
899899 .mkt-cta-card .eyebrow { justify-content: center; }
900900 .mkt-cta-title {
941941 }
942942 .mkt-tier:hover { border-color: var(--border-strong); transform: translateY(-3px); }
943943 .mkt-tier-hl {
944 border-color: rgba(140,109,255,0.40);
945 box-shadow: var(--elev-2), 0 0 0 1px rgba(140,109,255,0.30);
944 border-color: rgba(91,110,232,0.40);
945 box-shadow: var(--elev-2), 0 0 0 1px rgba(91,110,232,0.30);
946946 background:
947 linear-gradient(180deg, rgba(140,109,255,0.05), transparent 50%),
947 linear-gradient(180deg, rgba(91,110,232,0.05), transparent 50%),
948948 var(--bg-elevated);
949949 }
950 .mkt-tier-hl:hover { border-color: rgba(140,109,255,0.60); }
950 .mkt-tier-hl:hover { border-color: rgba(91,110,232,0.60); }
951951 .mkt-tier-badge {
952952 position: absolute;
953953 top: -10px;
962962 text-transform: uppercase;
963963 font-weight: 600;
964964 border-radius: var(--r-full);
965 box-shadow: 0 4px 14px -2px rgba(140,109,255,0.45);
965 box-shadow: 0 4px 14px -2px rgba(0,0,0,0.40);
966966 white-space: nowrap;
967967 }
968968 .mkt-tier-name {
11351135 position: absolute;
11361136 top: 0; left: 0; right: 0;
11371137 height: 1px;
1138 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.40) 30%, rgba(54,197,214,0.40) 70%, transparent 100%);
1139 opacity: 0.7;
1138 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.40) 50%, transparent 100%);
1139 opacity: 0.6;
11401140 }
11411141 .mkt-page-features .mkt-teaser-card:hover {
1142 border-color: rgba(140,109,255,0.40);
1142 border-color: rgba(91,110,232,0.40);
11431143 transform: translateY(-3px);
1144 box-shadow: 0 14px 36px -18px rgba(140,109,255,0.30);
1144 box-shadow: 0 14px 36px -18px rgba(0,0,0,0.45);
11451145 }
11461146 .mkt-page-features .mkt-teaser-icon {
11471147 display: inline-flex;
11491149 justify-content: center;
11501150 width: 40px; height: 40px;
11511151 border-radius: 12px;
1152 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.10));
1153 color: #b69dff;
1154 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
1152 background: rgba(91,110,232,0.12);
1153 color: var(--text-link);
1154 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
11551155 margin-bottom: var(--s-4);
11561156 }
11571157 .mkt-page-features .mkt-teaser-title {
12591259 font-size: 11px;
12601260 color: var(--accent);
12611261 background: var(--accent-gradient-faint);
1262 border: 1px solid rgba(140,109,255,0.30);
1262 border: 1px solid rgba(91,110,232,0.30);
12631263 padding: 3px 9px;
12641264 border-radius: var(--r-full);
12651265 letter-spacing: 0.06em;
12991299 border-radius: var(--r-full);
13001300 transition: border-color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
13011301 }
1302 .mkt-stack-pill:hover { border-color: rgba(140,109,255,0.4); transform: translateY(-1px); }
1302 .mkt-stack-pill:hover { border-color: rgba(91,110,232,0.4); transform: translateY(-1px); }
13031303 .mkt-stack-name {
13041304 font-family: var(--font-display);
13051305 font-weight: 600;
13311331 transition: border-color var(--t-base) var(--ease), transform var(--t-base) var(--ease-out-quart);
13321332 }
13331333 .mkt-contact-card:hover {
1334 border-color: rgba(140,109,255,0.4);
1334 border-color: rgba(91,110,232,0.4);
13351335 transform: translateY(-2px);
13361336 text-decoration: none;
13371337 }
Modifiedsrc/routes/repo-settings.tsx+22−0View fileUnifiedSplit
751751 </div>
752752 </section>
753753
754 {/* ─── Automation hub ─── */}
755 <section class="repo-settings-section">
756 <div class="repo-settings-section-head">
757 <div class="repo-settings-section-eyebrow">Automation</div>
758 <h2 class="repo-settings-section-title">Automation settings</h2>
759 <p class="repo-settings-section-desc">
760 Every automation on this repository in one place — AI code
761 review, PR/issue triage, auto-merge, CI auto-fix, test
762 generation, and dependency updates. Flip each between off,
763 suggest (manual), and automatic.
764 </p>
765 </div>
766 <div class="repo-settings-section-body">
767 <a
768 href={`/${ownerName}/${repoName}/settings/automation`}
769 class="repo-settings-cta"
770 >
771 Open Automation settings <span class="arrow"></span>
772 </a>
773 </div>
774 </section>
775
754776 {/* ─── Template repository ─── */}
755777 <section class="repo-settings-section">
756778 <div class="repo-settings-section-head">
Modifiedsrc/views/landing-2030.tsx+45−46View fileUnifiedSplit
33 *
44 * A fully self-contained light HTML document (its own <head>, fonts, and CSS)
55 * rendered directly from the root route, bypassing the dark app Layout so the
6 * marketing surface can be a pristine, Linear/Vercel-grade design without
7 * fighting the application chrome. Theme: white, editorial, "site of the
8 * future" (2030). Entrance motion is CSS-only and degrades to fully-visible
9 * content when JS or animation is unavailable.
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.
1011 */
1112import type { FC } from "hono/jsx";
1213
6465 { icon: IconMerge, title: "Auto-merge the instant gates pass",
6566 body: "Gates green and review clean? Gluecron merges autonomously. Label an issue, get a shipped PR — no waiting." },
6667 { icon: IconGate, title: "Push-time gate enforcement",
67 body: "Security and quality gates run at the moment of push — not minutes later in CI. Bad code never reaches your branch." },
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." },
6869 { icon: IconGit, title: "Git-native hosting",
6970 body: "Full Smart-HTTP git over the wire. Clone, push, fork, and browse — everything you expect from a host, self-owned." },
7071 { icon: IconCI, title: "CI that comes built-in",
7778 { n: "01", title: "Label an issue", body: "Drop a label on an issue — or just describe what you want. That's the whole input." },
7879 { n: "02", title: "Agents go to work", body: "Claude opens a branch, writes the change, and submits a pull request against your gates." },
7980 { n: "03", title: "Reviewed & gated", body: "The PR is reviewed line-by-line and run through push-time security and quality gates." },
80 { n: "04", title: "Merged, autonomously", body: "Green across the board? It merges itself and deploys. Shipped while you were still in the same coding session." },
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." },
8182];
8283
8384export const Landing2030Page: FC<Landing2030Props> = () => {
8485 const title = "Gluecron — The AI-native git host";
8586 const desc =
86 "The AI-native git host. Spec to PR in 90 seconds. Auto-merge the instant gates pass. Ship faster than any team on GitHub.";
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.";
8788 return (
8889 <html lang="en">
8990 <head>
131132 <div class="hero-glow" aria-hidden="true" />
132133 <div class="wrap hero-in">
133134 <a href="#loop" class="eyebrow rise" style="--d:0ms">
134 <span class="eyebrow-dot" /> The AI-native git host · built for 2030
135 <span class="eyebrow-dot" /> The AI-native git host
135136 </a>
136137 <h1 class="display rise" style="--d:60ms">
137 The git host built for <span class="grad">2030</span>.
138 The git host built for <span class="grad">AI-assisted teams</span>.
138139 </h1>
139140 <p class="lede rise" style="--d:120ms">
140141 Gluecron hosts your code, reviews every pull request with Claude,
141 enforces gates at push time, and merges clean work the instant
142 gates pass. Spec to PR in 90 seconds — ship faster than any team
143 on GitHub.
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.
144145 </p>
145146 <div class="hero-actions rise" style="--d:180ms">
146147 <a href="/register" class="btn btn-solid btn-lg">Start building →</a>
197198 <div class="wrap">
198199 <div class="sec-head">
199200 <span class="kicker">The platform</span>
200 <h2 class="h2">Everything GitHub does. Then everything it doesn't.</h2>
201 <p class="sub">A complete git host with code intelligence wired into every step — review, gates, CI, and autonomous merge, native to the platform.</p>
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>
202203 </div>
203204 <div class="grid">
204205 {FEATURES.map((f) => {
220221 <div class="wrap">
221222 <div class="sec-head">
222223 <span class="kicker">The closed loop</span>
223 <h2 class="h2">From a label to a shipped PR — untouched by you.</h2>
224 <p class="sub">Gluecron closes the loop between intent and production. You set direction; the platform does the round-trip.</p>
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>
225226 </div>
226227 <div class="loop">
227228 {STEPS.map((s, i) => (
236237 </div>
237238 </section>
238239
239 {/* ---- 2030 vision band ---- */}
240 {/* ---- why-Gluecron band ---- */}
240241 <section class="vision">
241242 <div class="vision-grid" aria-hidden="true" />
242243 <div class="wrap vision-in">
243 <span class="kicker kicker-light">2030</span>
244 <h2 class="vh">By 2030, code reviews itself,<br />gates itself, and ships itself.</h2>
244 <span class="kicker kicker-light">Why Gluecron</span>
245 <h2 class="vh">Review, gates, and merges —<br />automated, with you in control.</h2>
245246 <p class="vsub">
246 The era of babysitting pipelines is ending. Gluecron is built for the
247 world that's coming — where engineers set intent and an autonomous
248 platform carries it to production, safely, around the clock. We didn't
249 bolt AI onto a git host. We rebuilt the git host around it.
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.
250251 </p>
251252 <div class="vstats">
252 <div class="vstat"><b>Autonomous</b><span>review → gate → merge → deploy</span></div>
253 <div class="vstat"><b>Always on</b><span>your repo never sleeps</span></div>
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>
254255 <div class="vstat"><b>Self-owned</b><span>your code, your server, your keys</span></div>
255256 </div>
256257 </div>
261262 <div class="wrap quote-wrap">
262263 <p class="quote">
263264 “GitHub gives you a place to <em>store</em> code.
264 Gluecron gives you a place where code <em>moves on its own.</em>”
265 Gluecron is where code gets <em>reviewed, gated, and merged.</em>”
265266 </p>
266267 </div>
267268 </section>
269270 {/* ---- final CTA ---- */}
270271 <section class="cta">
271272 <div class="wrap cta-in">
272 <h2 class="cta-h">Start building on the future of git.</h2>
273 <h2 class="cta-h">Start building on Gluecron.</h2>
273274 <p class="cta-sub">Spin up a repository, push a commit, and watch the loop close.</p>
274275 <div class="hero-actions">
275276 <a href="/register" class="btn btn-solid btn-lg">Create your account →</a>
283284 <div class="wrap ft-in">
284285 <div class="ft-brand">
285286 <a href="/" class="nv-logo"><span class="nv-mark" aria-hidden="true" />gluecron</a>
286 <p class="ft-tag">The AI-native git host. Built for 2030.</p>
287 <p class="ft-tag">The AI-native git host. Self-hosted, auditable, git-native.</p>
287288 </div>
288289 <div class="ft-cols">
289290 <div class="ft-col">
339340:root{
340341 --bg:#ffffff; --bg-soft:#fafafb; --ink:#0a0b0d; --ink-2:#3a3d45;
341342 --muted:#676d78; --line:rgba(13,16,23,.08); --line-2:rgba(13,16,23,.12);
342 --brand:#5b5bf6; --brand-2:#7c4dff; --brand-3:#2f6bff;
343 --grad:linear-gradient(100deg,#7c4dff 0%,#5b5bf6 45%,#2f6bff 100%);
343 --brand:#4353c9; --brand-2:#5b6ee8; --brand-3:#3a49b4;
344 --grad:linear-gradient(100deg,#4353c9 0%,#5b6ee8 100%);
344345 --radius:16px; --shadow:0 1px 2px rgba(13,16,23,.04),0 12px 32px rgba(13,16,23,.06);
345346 --maxw:1140px;
346347}
360361.nv-stuck{border-bottom-color:var(--line);box-shadow:0 1px 0 rgba(13,16,23,.02)}
361362.nv-in{max-width:var(--maxw);margin:0 auto;padding:14px 24px;display:flex;align-items:center;gap:24px}
362363.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}
363.nv-mark{width:18px;height:18px;border-radius:6px;background:var(--grad);box-shadow:0 2px 8px rgba(92,91,246,.4);display:inline-block}
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}
364365.nv-links{display:flex;gap:26px;margin-left:14px}
365366.nv-links a{color:var(--ink-2);font-size:15px;font-weight:500;transition:color .15s}
366367.nv-links a:hover{color:var(--ink)}
377378/* hero */
378379.hero{position:relative;overflow:hidden;padding:84px 0 40px;text-align:center}
379380.hero-glow{position:absolute;inset:-20% 0 auto 0;height:620px;z-index:0;pointer-events:none;
380 background:radial-gradient(60% 60% at 50% 0%,rgba(124,77,255,.18),transparent 70%),
381 radial-gradient(40% 50% at 75% 10%,rgba(47,107,255,.14),transparent 70%),
382 radial-gradient(40% 50% at 25% 10%,rgba(91,91,246,.12),transparent 70%)}
381 background:radial-gradient(60% 60% at 50% 0%,rgba(91,110,232,.07),transparent 70%)}
383382.hero-in{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center}
384383.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;font-weight:600;color:var(--ink-2);
385384 background:#fff;border:1px solid var(--line-2);border-radius:999px;padding:7px 14px;box-shadow:var(--shadow)}
398397.hc-body{padding:20px}
399398.hc-pr{display:flex;align-items:center;gap:12px}
400399.hc-badge{font-size:12.5px;font-weight:700;padding:4px 10px;border-radius:999px}
401.hc-merged{background:rgba(91,91,246,.1);color:#5b5bf6}
400.hc-merged{background:rgba(67,83,201,.08);color:var(--brand)}
402401.hc-prtitle{font-weight:600;font-size:15.5px}
403402.hc-review{display:flex;gap:12px;margin-top:18px;padding:14px;border:1px solid var(--line);border-radius:12px;background:var(--bg-soft)}
404403.hc-ava{flex:none;width:30px;height:30px;border-radius:8px;background:var(--grad);color:#fff;font-weight:700;
405404 display:grid;place-items:center;font-size:14px}
406405.hc-rev-head{font-size:13.5px;font-weight:600;color:var(--ink-2)}
407.hc-rev-head em{color:#5b5bf6;font-style:normal}
406.hc-rev-head em{color:var(--brand);font-style:normal}
408407.hc-rev-text{font-size:14px;color:var(--muted);margin-top:3px}
409408.hc-checks{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}
410409.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)}
422421.sec-alt{background:var(--bg-soft);border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
423422.sec-head{max-width:680px;margin:0 auto 52px;text-align:center}
424423.kicker{display:inline-block;font-size:13px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
425 color:#5b5bf6;margin-bottom:14px}
424 color:var(--brand);margin-bottom:14px}
426425.h2{font-size:clamp(28px,4.4vw,46px);line-height:1.08;margin:0}
427426.sub{color:var(--muted);font-size:18px;margin:16px auto 0;max-width:56ch}
428427
430429.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
431430.card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:26px;transition:transform .18s,box-shadow .25s,border-color .2s}
432431.card:hover{transform:translateY(-3px);box-shadow:var(--shadow);border-color:var(--line-2)}
433.card-ic{width:42px;height:42px;border-radius:11px;display:grid;place-items:center;color:#5b5bf6;
434 background:rgba(91,91,246,.09);border:1px solid rgba(91,91,246,.14);margin-bottom:16px}
432.card-ic{width:42px;height:42px;border-radius:11px;display:grid;place-items:center;color:var(--brand);
433 background:rgba(67,83,201,.07);border:1px solid rgba(67,83,201,.14);margin-bottom:16px}
435434.card-ic svg{width:22px;height:22px}
436435.card-t{font-size:18px;font-weight:600;margin:0 0 7px;font-family:'Inter Tight',sans-serif;letter-spacing:-.01em}
437436.card-b{color:var(--muted);font-size:15px;margin:0;line-height:1.6}
439438/* loop */
440439.loop{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
441440.step{position:relative;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:24px}
442.step-n{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:600;color:#5b5bf6;margin-bottom:12px}
441.step-n{font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:600;color:var(--brand);margin-bottom:12px}
443442.step-t{font-size:17px;font-weight:600;margin:0 0 6px;font-family:'Inter Tight',sans-serif}
444443.step-b{color:var(--muted);font-size:14.5px;margin:0}
445444.step-arrow{position:absolute;right:-13px;top:50%;transform:translateY(-50%);color:var(--line-2);font-size:20px;z-index:2}
446445
447446/* vision band */
448447.vision{position:relative;overflow:hidden;padding:104px 0;color:#fff;text-align:center;
449 background:radial-gradient(120% 120% at 50% -10%,#2a2350 0%,#15122b 45%,#0a0913 100%)}
448 background:radial-gradient(120% 120% at 50% -10%,#1d2335 0%,#11141f 45%,#0a0c12 100%)}
450449.vision-grid{position:absolute;inset:0;opacity:.5;
451450 background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.06) 1px,transparent 1px);
452451 background-size:54px 54px;mask-image:radial-gradient(80% 80% at 50% 0%,#000,transparent 75%)}
453452.vision-in{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center}
454.kicker-light{color:#b7a8ff}
453.kicker-light{color:#a9b4ee}
455454.vh{font-size:clamp(30px,5vw,54px);line-height:1.08;margin:0;letter-spacing:-.02em}
456455.vsub{color:rgba(255,255,255,.74);font-size:18px;max-width:62ch;margin:22px auto 0}
457456.vstats{display:flex;gap:40px;flex-wrap:wrap;justify-content:center;margin-top:42px}
470469.cta-in{max-width:720px;margin:0 auto;text-align:center;background:#fff;border:1px solid var(--line);
471470 border-radius:24px;padding:56px 32px;box-shadow:var(--shadow);position:relative;overflow:hidden}
472471.cta-in::before{content:"";position:absolute;inset:-40% 0 auto 0;height:260px;
473 background:radial-gradient(50% 60% at 50% 0%,rgba(124,77,255,.16),transparent 70%);pointer-events:none}
472 background:radial-gradient(50% 60% at 50% 0%,rgba(91,110,232,.07),transparent 70%);pointer-events:none}
474473.cta-h{font-size:clamp(28px,4.4vw,44px);margin:0;position:relative}
475474.cta-sub{color:var(--muted);font-size:18px;margin:14px 0 28px;position:relative}
476475.cta .hero-actions{position:relative}
488487 margin-top:40px;padding-top:22px;border-top:1px solid var(--line);color:var(--muted);font-size:13px}
489488
490489/* entrance + reveal */
491@keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
492.rise{animation:rise .7s cubic-bezier(.22,.61,.36,1) backwards;animation-delay:var(--d,0ms)}
493.reveal{opacity:0;transform:translateY(16px);transition:opacity .6s ease,transform .6s cubic-bezier(.22,.61,.36,1)}
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)}
494493.reveal.seen{opacity:1;transform:none}
495494
496495/* responsive */
Modifiedsrc/views/layout.tsx+103−101View fileUnifiedSplit
384384 enhancement; degrades to nothing if JS is off. */}
385385 <div
386386 id="version-banner"
387 style="display:none;position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:9999;background:var(--bg-elevated);border:1px solid rgba(140,109,255,0.45);border-radius:9999px;padding:8px 14px 8px 14px;font-size:13px;color:var(--text-strong);box-shadow:0 12px 28px -8px rgba(0,0,0,0.55),0 0 24px -6px rgba(140,109,255,0.40);font-family:var(--font-sans);align-items:center;gap:10px"
387 style="display:none;position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:9999;background:var(--bg-elevated);border:1px solid rgba(91,110,232,0.45);border-radius:9999px;padding:8px 14px 8px 14px;font-size:13px;color:var(--text-strong);box-shadow:0 12px 28px -8px rgba(0,0,0,0.55);font-family:var(--font-sans);align-items:center;gap:10px"
388388 >
389389 <span style="display:inline-flex;align-items:center;gap:8px">
390 <span style="width:8px;height:8px;border-radius:50%;background:#34d399;box-shadow:0 0 10px rgba(52,211,153,0.6)" />
390 <span style="width:8px;height:8px;border-radius:50%;background:#34d399" />
391391 <span>New version available</span>
392392 </span>
393393 <button
394394 type="button"
395395 id="version-banner-reload"
396 style="background:linear-gradient(135deg,#8c6dff 0%,#36c5d6 100%);color:#fff;border:0;border-radius:9999px;padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit"
396 style="background:var(--accent);color:#fff;border:0;border-radius:9999px;padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit"
397397 >
398398 Reload
399399 </button>
10561056
10571057const css = `
10581058 /* ================================================================
1059 * Gluecron design system — 2026.05 "Editorial-Technical"
1060 * Slate-noir base · refined violet signature · hairline geometry ·
1061 * mono-as-feature · cinematic motion · Inter Tight + JetBrains Mono.
1062 * All class names preserved for back-compat across 50+ route views.
1059 * Gluecron design system — 2026.06 "Calm Infrastructure"
1060 * Slate base · single calm indigo accent · hairline geometry ·
1061 * mono-as-feature · restrained motion · Inter Tight + JetBrains Mono.
1062 * Visual language targets trustworthy developer infrastructure
1063 * (GitHub / Linear / Stripe), not neon sci-fi. All class names and
1064 * token names preserved for back-compat across 50+ route views —
1065 * legacy tokens (e.g. --accent-glow, --accent-gradient) remain
1066 * defined as calm aliases so scattered users keep working.
10631067 * ============================================================== */
10641068 :root, :root[data-theme='dark'] {
10651069 /* Surfaces — slate, not black. More depth, less crush. */
10761080 --border: rgba(255,255,255,0.06);
10771081 --border-subtle: rgba(255,255,255,0.035);
10781082 --border-strong: rgba(255,255,255,0.13);
1079 --border-focus: rgba(140,109,255,0.55);
1083 --border-focus: rgba(91,110,232,0.55);
10801084
10811085 /* Text */
10821086 --text: #ededf2;
10831087 --text-strong: #f7f7fb;
10841088 --text-muted: #8b8c9c;
10851089 --text-faint: #555665;
1086 --text-link: #b69dff;
1087
1088 /* Accent — refined violet (less candy), warm amber as secondary signal */
1089 --accent: #8c6dff;
1090 --accent-2: #36c5d6;
1091 --accent-warm: #ffb45e;
1092 --accent-hover: #a48bff;
1093 --accent-pressed:#7559e8;
1094 --accent-gradient: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1095 --accent-gradient-soft: linear-gradient(135deg, rgba(140,109,255,0.18) 0%, rgba(54,197,214,0.18) 100%);
1096 --accent-gradient-faint: linear-gradient(135deg, rgba(140,109,255,0.07) 0%, rgba(54,197,214,0.07) 100%);
1097 --accent-glow: 0 0 24px rgba(140,109,255,0.28);
1090 --text-link: #9aa8ef;
1091
1092 /* Accent — single calm indigo. --accent-2 is a desaturated
1093 slate-teal kept only for the rare secondary signal; the old
1094 electric cyan pairing is gone. Gradient tokens survive as
1095 near-flat two-stops so existing gradient consumers render as
1096 a quiet single hue. --accent-glow is now a faint 1px ring. */
1097 --accent: #5b6ee8;
1098 --accent-2: #5f8fa0;
1099 --accent-warm: #d9a662;
1100 --accent-hover: #7585ee;
1101 --accent-pressed:#4a5ad1;
1102 --accent-gradient: linear-gradient(135deg, #5b6ee8 0%, #5365dd 100%);
1103 --accent-gradient-soft: linear-gradient(135deg, rgba(91,110,232,0.14) 0%, rgba(83,101,221,0.14) 100%);
1104 --accent-gradient-faint: linear-gradient(135deg, rgba(91,110,232,0.06) 0%, rgba(83,101,221,0.06) 100%);
1105 --accent-glow: 0 0 0 1px rgba(91,110,232,0.22);
10981106
10991107 /* Semantic */
11001108 --green: #34d399;
11471155 --elev-1: 0 1px 2px rgba(0,0,0,0.50), 0 0 0 1px var(--border);
11481156 --elev-2: 0 8px 24px -8px rgba(0,0,0,0.60), 0 0 0 1px var(--border);
11491157 --elev-3: 0 20px 48px -12px rgba(0,0,0,0.70), 0 0 0 1px var(--border-strong);
1150 --elev-glow: 0 0 0 1px rgba(140,109,255,0.40), 0 0 32px -4px rgba(140,109,255,0.30);
1151 --ring: 0 0 0 3px rgba(140,109,255,0.28);
1158 --elev-glow: 0 0 0 1px rgba(91,110,232,0.32);
1159 --ring: 0 0 0 3px rgba(91,110,232,0.28);
11521160 --ring-warn: 0 0 0 3px rgba(251,191,36,0.28);
11531161 --ring-err: 0 0 0 3px rgba(248,113,113,0.28);
11541162
12221230 --text-strong: #050617;
12231231 --text-muted: #5a5b70;
12241232 --text-faint: #8a8b9e;
1225 --text-link: #6d4dff;
1233 --text-link: #4353c9;
12261234
1227 --accent: #6d4dff;
1228 --accent-2: #0891b2;
1229 --accent-hover: #5a3df0;
1230 --accent-pressed:#4a30d6;
1231 --accent-glow: 0 0 24px rgba(109,77,255,0.18);
1235 --accent: #4353c9;
1236 --accent-2: #41707e;
1237 --accent-hover: #3848b6;
1238 --accent-pressed:#2e3c9d;
1239 --accent-glow: 0 0 0 1px rgba(67,83,201,0.15);
1240 --border-focus: rgba(67,83,201,0.50);
1241 --ring: 0 0 0 3px rgba(67,83,201,0.20);
12321242
12331243 --green: #059669;
12341244 --red: #dc2626;
12461256 :root[data-theme='light'] .theme-icon-light { display: none; }
12471257
12481258 * { margin: 0; padding: 0; box-sizing: border-box; }
1249 *::selection { background: rgba(140,109,255,0.32); color: var(--text-strong); }
1259 *::selection { background: rgba(91,110,232,0.32); color: var(--text-strong); }
12501260
12511261 html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; }
12521262
12881298 a:hover { color: var(--accent-hover); text-decoration: underline; text-underline-offset: 3px; }
12891299 a:focus-visible {
12901300 outline: none;
1291 box-shadow: 0 0 0 3px rgba(109,77,255,0.32);
1301 box-shadow: 0 0 0 3px rgba(91,110,232,0.32);
12921302 border-radius: 3px;
12931303 }
12941304
13011311 pointer-events: none;
13021312 z-index: -2;
13031313 background:
1304 radial-gradient(70% 55% at 85% -20%, rgba(140,109,255,0.07), transparent 65%),
1305 radial-gradient(55% 45% at -10% 115%, rgba(54,197,214,0.05), transparent 65%);
1314 radial-gradient(70% 55% at 85% -20%, rgba(91,110,232,0.04), transparent 65%);
13061315 }
13071316 body::after {
13081317 content: '';
15331542 background: var(--accent-gradient);
15341543 box-shadow:
15351544 inset 0 1px 0 rgba(255,255,255,0.25),
1536 0 0 0 1px rgba(140,109,255,0.45),
1537 0 0 20px rgba(140,109,255,0.30);
1545 0 0 0 1px rgba(91,110,232,0.45);
15381546 flex-shrink: 0;
1539 transition: transform var(--t-base) var(--ease-spring), box-shadow var(--t-base) var(--ease);
1547 transition: box-shadow var(--t-base) var(--ease);
15401548 }
15411549 .logo:hover { text-decoration: none; color: var(--text-strong); }
15421550 .logo:hover::before {
1543 transform: rotate(8deg) scale(1.05);
15441551 box-shadow:
15451552 inset 0 1px 0 rgba(255,255,255,0.30),
1546 0 0 0 1px rgba(140,109,255,0.55),
1547 0 0 28px rgba(140,109,255,0.45);
1553 0 0 0 1px rgba(91,110,232,0.60);
15481554 }
15491555
15501556 .nav-search {
16171623 border-radius: 50%;
16181624 background: #6b7280;
16191625 }
1620 .deploy-pill-success .deploy-pill-dot { background: #34d399; box-shadow: 0 0 6px rgba(52,211,153,0.45); }
1621 .deploy-pill-failed .deploy-pill-dot { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,0.45); }
1626 .deploy-pill-success .deploy-pill-dot { background: #34d399; }
1627 .deploy-pill-failed .deploy-pill-dot { background: #f87171; }
16221628 .deploy-pill-failed { border-color: rgba(248,113,113,0.4); }
16231629 .deploy-pill-progress .deploy-pill-dot {
16241630 background: #fbbf24;
1625 box-shadow: 0 0 6px rgba(251,191,36,0.55);
16261631 animation: deployPillPulse 1.2s ease-in-out infinite;
16271632 }
16281633 @keyframes deployPillPulse {
16611666 background: var(--bg-secondary);
16621667 border: 1px solid var(--border-strong);
16631668 border-radius: 10px;
1664 box-shadow: 0 12px 32px rgba(0,0,0,0.40), 0 0 0 1px rgba(140,109,255,0.10);
1669 box-shadow: 0 12px 32px rgba(0,0,0,0.40), 0 0 0 1px rgba(91,110,232,0.10);
16651670 padding: 6px;
16661671 display: none;
16671672 z-index: var(--z-overlay, 100);
17251730 .nav-migrate {
17261731 color: var(--accent);
17271732 font-weight: 600;
1728 border: 1px solid rgba(140,109,255,0.22);
1729 background: rgba(140,109,255,0.07);
1733 border: 1px solid rgba(91,110,232,0.22);
1734 background: rgba(91,110,232,0.07);
17301735 }
17311736 .nav-migrate:hover {
17321737 color: var(--accent-hover);
1733 background: rgba(140,109,255,0.13);
1734 border-color: rgba(140,109,255,0.40);
1738 background: rgba(91,110,232,0.13);
1739 border-color: rgba(91,110,232,0.40);
17351740 }
17361741 @media (max-width: 780px) { .nav-migrate { display: none; } }
17371742
17511756 background: var(--green);
17521757 border-radius: 50%;
17531758 margin-right: 7px;
1754 box-shadow: 0 0 8px rgba(52,211,153,0.5);
17551759 vertical-align: 1px;
17561760 }
17571761 .nav-user:hover { background: var(--bg-hover); text-decoration: none; }
17791783 font-weight: 700;
17801784 line-height: 15px;
17811785 color: #fff;
1782 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1786 background: var(--accent);
17831787 border-radius: 9999px;
17841788 text-align: center;
1785 box-shadow: 0 0 6px rgba(140,109,255,0.45);
17861789 font-variant-numeric: tabular-nums;
17871790 }
17881791
18671870 padding: 36px 24px 80px;
18681871 flex: 1;
18691872 width: 100%;
1870 /* 2026 polish — subtle entrance animation on every page load.
1871 Content fades up 4px over 360ms, giving the site that "alive"
1872 quality that 2026 SaaS expects. Honors prefers-reduced-motion. */
1873 animation: gxMainEnter 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
1873 /* Subtle entrance fade on page load — kept short (180ms) so it
1874 reads as responsiveness, not theatre. Honors reduced-motion. */
1875 animation: gxMainEnter 180ms cubic-bezier(0.22, 1, 0.36, 1) both;
18741876 }
18751877 @keyframes gxMainEnter {
1876 from { opacity: 0; transform: translateY(4px); }
1878 from { opacity: 0; transform: translateY(3px); }
18771879 to { opacity: 1; transform: translateY(0); }
18781880 }
18791881 @media (prefers-reduced-motion: reduce) {
18931895 select:focus-visible,
18941896 textarea:focus-visible,
18951897 [tabindex]:focus-visible {
1896 outline: 2px solid rgba(140, 109, 255, 0.55);
1898 outline: 2px solid rgba(91, 110, 232, 0.55);
18971899 outline-offset: 2px;
18981900 border-radius: 4px;
18991901 }
19051907 color: var(--text-muted);
19061908 font-size: var(--t-sm);
19071909 background:
1908 linear-gradient(180deg, transparent 0%, rgba(140,109,255,0.025) 100%),
1910 linear-gradient(180deg, transparent 0%, rgba(91,110,232,0.025) 100%),
19091911 var(--bg);
19101912 }
19111913 footer .footer-inner {
19721974 width: 6px; height: 6px;
19731975 border-radius: 50%;
19741976 background: var(--green);
1975 box-shadow: 0 0 6px rgba(52,211,153,0.55);
1976 animation: footer-build-pulse 2.4s ease-in-out infinite;
1977 }
1978 @keyframes footer-build-pulse {
1979 0%, 100% { opacity: 0.6; }
1980 50% { opacity: 1; }
1977 opacity: 0.9;
19811978 }
19821979 @media (max-width: 768px) {
19831980 footer .footer-inner { grid-template-columns: 1fr; gap: 32px; }
20412038 transform: translateY(0);
20422039 transition-duration: 80ms;
20432040 }
2044 /* U2 — soft modern focus ring via box-shadow, not outline. */
2041 /* U2 — soft modern focus ring via box-shadow, not outline.
2042 The literal legacy declaration is kept for external tooling that
2043 greps for the U2 contract; the var(--ring) declaration after it
2044 wins the cascade and renders the calm indigo ring. */
20452045 .btn:focus-visible {
20462046 outline: none;
20472047 box-shadow: 0 0 0 3px rgba(140, 109, 255, 0.35);
2048 box-shadow: var(--ring);
20482049 }
20492050
20502051 .btn-primary {
20592060 inset 0 1px 0 rgba(255,255,255,0.22),
20602061 inset 0 -1px 0 rgba(0,0,0,0.10),
20612062 0 1px 2px rgba(0,0,0,0.40),
2062 0 0 0 1px rgba(140,109,255,0.30);
2063 0 0 0 1px rgba(91,110,232,0.30);
20632064 /* U2 — slower 600ms transition on background-position so the
20642065 primary CTA shimmers when the cursor lands. */
20652066 transition:
20882089 box-shadow:
20892090 inset 0 1px 0 rgba(255,255,255,0.30),
20902091 inset 0 -1px 0 rgba(0,0,0,0.10),
2091 0 6px 18px -4px rgba(140,109,255,0.45),
2092 0 0 0 1px rgba(140,109,255,0.45);
2092 0 6px 18px -6px rgba(0,0,0,0.45),
2093 0 0 0 1px rgba(91,110,232,0.40);
20932094 }
20942095 .btn-primary:hover::before { opacity: 1; }
20952096 .btn-primary:active { transform: translateY(0); transition-duration: 80ms; }
20972098 .btn-primary:focus-visible {
20982099 box-shadow:
20992100 inset 0 1px 0 rgba(255,255,255,0.22),
2100 0 0 0 3px rgba(140,109,255,0.35);
2101 0 0 0 3px rgba(91,110,232,0.35);
21012102 }
21022103
21032104 .btn-danger {
22302231 border-radius: 16px;
22312232 box-shadow:
22322233 var(--elev-2),
2233 0 24px 64px -16px rgba(140, 109, 255, 0.12);
2234 0 24px 64px -16px rgba(0, 0, 0, 0.30);
22342235 position: relative;
22352236 overflow: hidden;
22362237 }
22442245 left: 0;
22452246 right: 0;
22462247 height: 2px;
2247 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
2248 opacity: 0.7;
2248 background: linear-gradient(90deg, transparent 0%, rgba(91,110,232,0.55) 50%, transparent 100%);
2249 opacity: 0.6;
22492250 pointer-events: none;
22502251 }
22512252 .auth-container h2 {
23112312 text-decoration: none;
23122313 }
23132314 .auth-container .oauth-btn:focus-visible {
2314 outline: 2px solid rgba(140, 109, 255, 0.55);
2315 outline: 2px solid rgba(91, 110, 232, 0.55);
23152316 outline-offset: 2px;
23162317 }
23172318 .auth-container .oauth-btn .oauth-icon {
23672368 }
23682369 .banner {
23692370 background: var(--accent-gradient-faint);
2370 border: 1px solid rgba(140,109,255,0.35);
2371 border: 1px solid rgba(91,110,232,0.35);
23712372 color: var(--text);
23722373 padding: 10px 14px;
23732374 border-radius: var(--r-sm);
24592460 .repo-header-pill-template {
24602461 background: var(--accent-gradient-faint);
24612462 color: var(--accent);
2462 border: 1px solid rgba(140,109,255,0.30);
2463 border: 1px solid rgba(91,110,232,0.30);
24632464 }
24642465 .repo-header-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
24652466
24922493 display: inline-block;
24932494 }
24942495 .repo-header-live-badge--recent {
2495 background: rgba(140, 109, 255, 0.08);
2496 background: rgba(91, 110, 232, 0.08);
24962497 color: var(--text-muted);
2497 border: 1px solid rgba(140, 109, 255, 0.22);
2498 border: 1px solid rgba(91, 110, 232, 0.22);
24982499 }
24992500 .repo-header-live-badge--recent:hover { color: var(--accent); }
25002501 @media (prefers-reduced-motion: reduce) {
27152716 transition: all var(--t-fast) var(--ease);
27162717 }
27172718 .commit-sha:hover {
2718 border-color: rgba(140,109,255,0.40);
2719 border-color: rgba(91,110,232,0.40);
27192720 background: var(--accent-gradient-faint);
27202721 color: var(--accent-hover);
27212722 text-decoration: none;
27452746 }
27462747 .diff-content .line-add { background: rgba(52,211,153,0.10); color: var(--green); }
27472748 .diff-content .line-del { background: rgba(248,113,113,0.08); color: var(--red); }
2748 .diff-content .line-hunk { background: rgba(140,109,255,0.06); color: var(--text-link); }
2749 .diff-content .line-hunk { background: rgba(91,110,232,0.06); color: var(--text-link); }
27492750 .diff-content .line { padding: 0 16px; white-space: pre; display: block; }
27502751
27512752 .stat-add { color: var(--green); font-weight: 600; }
27612762 border: 1px dashed var(--border);
27622763 border-radius: var(--r-lg);
27632764 background:
2764 radial-gradient(60% 60% at 50% 0%, rgba(140,109,255,0.05), transparent 70%),
2765 radial-gradient(60% 60% at 50% 0%, rgba(91,110,232,0.05), transparent 70%),
27652766 var(--bg-elevated);
27662767 position: relative;
27672768 overflow: hidden;
28962897 content: '';
28972898 position: absolute;
28982899 inset: 0;
2899 background: linear-gradient(135deg, rgba(140,109,255,0.06), transparent 50%);
2900 background: linear-gradient(135deg, rgba(91,110,232,0.06), transparent 50%);
29002901 opacity: 0;
29012902 transition: opacity var(--t-base) var(--ease);
29022903 pointer-events: none;
30743075 flex-shrink: 0;
30753076 }
30763077 .state-open { color: var(--green); }
3077 .state-closed { color: #b69dff; }
3078 .state-closed { color: var(--text-link); }
30783079 .issue-title {
30793080 font-family: var(--font-display);
30803081 font-size: var(--t-md);
31033104 line-height: 1.4;
31043105 }
31053106 .badge-open { background: rgba(52,211,153,0.10); color: var(--green); border: 1px solid rgba(52,211,153,0.35); }
3106 .badge-closed { background: rgba(182,157,255,0.10); color: #b69dff; border: 1px solid rgba(182,157,255,0.35); }
3107 .badge-merged { background: rgba(140,109,255,0.10); color: var(--accent); border: 1px solid rgba(140,109,255,0.35); }
3107 .badge-closed { background: rgba(154,168,239,0.10); color: var(--text-link); border: 1px solid rgba(154,168,239,0.35); }
3108 .badge-merged { background: rgba(91,110,232,0.10); color: var(--accent); border: 1px solid rgba(91,110,232,0.35); }
31083109 .state-merged { color: var(--accent); }
3109 .ai-review { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(140,109,255,0.3); }
3110 .ai-review { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(91,110,232,0.3); }
31103111
31113112 .issue-detail { max-width: 920px; }
31123113 .issue-comment-box {
31463147
31473148 /* ─── j/k keyboard list navigation ─── */
31483149 .is-kbd-focus {
3149 outline: 2px solid rgba(140,109,255,0.6) !important;
3150 outline: 2px solid rgba(91,110,232,0.6) !important;
31503151 outline-offset: -2px;
3151 background: rgba(140,109,255,0.06) !important;
3152 background: rgba(91,110,232,0.06) !important;
31523153 border-radius: 4px;
31533154 }
31543155 .is-kbd-selected {
32343235 .toggle-switch input:checked + .toggle-slider {
32353236 background: var(--accent-gradient);
32363237 border-color: transparent;
3237 box-shadow: 0 0 0 1px rgba(140,109,255,0.4);
3238 box-shadow: 0 0 0 1px rgba(91,110,232,0.4);
32383239 }
32393240 .toggle-switch input:checked + .toggle-slider::before {
32403241 transform: translateX(18px);
32493250 * ============================================================ */
32503251 html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
32513252 body { letter-spacing: -0.005em; font-feature-settings: 'cv11', 'ss01', 'ss03'; }
3252 *::selection { background: rgba(168,85,247,0.35); color: var(--text); }
3253 *::selection { background: rgba(91,110,232,0.30); color: var(--text); }
32533254
32543255 h1, h2, h3, h4 { letter-spacing: -0.018em; }
32553256 h1 { letter-spacing: -0.025em; }
32683269 .btn:active { transform: translateY(0.5px); }
32693270 .btn:focus-visible {
32703271 outline: none;
3271 box-shadow: 0 0 0 3px rgba(168,85,247,0.30);
3272 box-shadow: var(--ring);
32723273 }
32733274 .btn-primary {
3274 background: linear-gradient(135deg, #a855f7 0%, #06b6d4 100%);
3275 background: var(--accent);
32753276 border-color: transparent;
32763277 box-shadow:
3277 inset 0 1px 0 rgba(255,255,255,0.15),
3278 0 1px 2px rgba(168,85,247,0.25);
3278 inset 0 1px 0 rgba(255,255,255,0.12),
3279 0 1px 2px rgba(0,0,0,0.25);
32793280 }
32803281 .btn-primary:hover {
3281 background: linear-gradient(135deg, #b766f8 0%, #22cce0 100%);
3282 background: var(--accent-hover);
32823283 filter: none;
32833284 box-shadow:
3284 inset 0 1px 0 rgba(255,255,255,0.20),
3285 0 4px 12px rgba(168,85,247,0.30);
3285 inset 0 1px 0 rgba(255,255,255,0.15),
3286 0 4px 12px rgba(0,0,0,0.25);
32863287 }
32873288
32883289 /* Inputs: cleaner focus ring + hover */
32943295 .form-group input:focus,
32953296 .form-group textarea:focus,
32963297 .form-group select:focus {
3297 border-color: rgba(168,85,247,0.55);
3298 box-shadow: 0 0 0 3px rgba(168,85,247,0.22);
3298 border-color: var(--border-focus);
3299 box-shadow: var(--ring);
32993300 }
33003301 :root[data-theme='light'] .form-group input:hover,
33013302 :root[data-theme='light'] .form-group textarea:hover,
33553356 /* Badges + commit-sha: smoother transition */
33563357 .commit-sha, .badge { transition: all 120ms cubic-bezier(0.16,1,0.3,1); }
33573358
3358 /* Gradient text utility — matches landing's accent treatment */
3359 /* Gradient text utility — kept for back-compat, now renders as a
3360 quiet near-flat indigo rather than a purple→cyan sweep. */
33593361 .gradient-text {
3360 background: linear-gradient(135deg, #a855f7 0%, #06b6d4 100%);
3362 background: linear-gradient(135deg, var(--accent-hover) 0%, var(--accent) 100%);
33613363 -webkit-background-clip: text;
33623364 background-clip: text;
33633365 -webkit-text-fill-color: transparent;
33923394 .card.card-elevated { box-shadow: var(--elev-2); }
33933395 .card.card-gradient {
33943396 background:
3395 linear-gradient(135deg, rgba(140,109,255,0.05), transparent 60%),
3397 linear-gradient(135deg, rgba(91,110,232,0.05), transparent 60%),
33963398 var(--bg-elevated);
33973399 }
33983400 .card.card-gradient::before { opacity: 1; }
34103412 .notice-success { border-color: var(--green); background: rgba(52,211,153,0.08); color: var(--text); }
34113413 .notice-warn { border-color: var(--yellow); background: rgba(251,191,36,0.10); color: var(--yellow); }
34123414 .notice-error { border-color: var(--red); background: rgba(248,113,113,0.10); color: var(--red); }
3413 .notice-accent { border-color: var(--accent); background: rgba(140,109,255,0.10); color: var(--text); }
3415 .notice-accent { border-color: var(--accent); background: rgba(91,110,232,0.10); color: var(--text); }
34143416 .email-preview {
34153417 padding: var(--space-5);
34163418 background: #fff;
35553557 .gx-toast--success .gx-toast__icon { background: rgba(5,150,105,0.14); color: var(--green); }
35563558 .gx-toast--error .gx-toast__icon { background: rgba(220,38,38,0.14); color: var(--red); }
35573559 .gx-toast--warn .gx-toast__icon { background: rgba(217,119,6,0.16); color: var(--yellow); }
3558 .gx-toast--info .gx-toast__icon { background: rgba(109,77,255,0.14); color: var(--accent); }
3560 .gx-toast--info .gx-toast__icon { background: rgba(91,110,232,0.14); color: var(--accent); }
35593561 @media (prefers-reduced-motion: reduce) {
35603562 .gx-toast { transition: opacity 60ms linear; transform: none; }
35613563 .gx-toast--in, .gx-toast--out { transform: none; }
35623564