Release ForgeFlow 0.8.9 Git Validator

This commit is contained in:
NuklearRabbit
2026-07-26 05:24:22 +02:00
parent e888bd2c1c
commit 24dde9a031
17 changed files with 1106 additions and 22 deletions
+416
View File
@@ -0,0 +1,416 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { run } = require("./process-runner.cjs");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
.env
.env.*
!.env.example
!.env.sample
# Dependencies and generated output
node_modules/
dist/
build/
coverage/
# Editors and operating systems
.idea/
.vscode/
.DS_Store
Thumbs.db
`;
function sameRemote(left, right) {
const a = normalizeRemoteUrl(left);
const b = normalizeRemoteUrl(right);
return Boolean(a && b && a.host === b.host && a.path === b.path);
}
function result(id, category, title, status, detail, options = {}) {
return {
id,
category,
title,
status,
detail,
weight: options.weight || 5,
fixAction: options.fixAction || null,
safe: options.safe === true,
confirmation: options.confirmation || null,
};
}
function isSensitiveTrackedPath(filePath) {
const value = String(filePath || "")
.replace(/\\/g, "/")
.toLowerCase();
if (/\.env\.(example|sample|template)$/.test(value)) return false;
return (
/(^|\/)\.env($|\.)/.test(value) ||
/(^|\/)(id_rsa|id_ed25519)$/.test(value) ||
/\.(pem|p12|pfx|key)$/.test(value) ||
/(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value)
);
}
class GitValidatorService {
constructor({ git, gitea, diagnostics }) {
this.git = git;
this.gitea = gitea;
this.diagnostics = diagnostics;
}
async config(root, key, { local = true } = {}) {
const response = await run(
"git",
["config", ...(local ? ["--local"] : []), "--get", key],
{
cwd: root,
timeout: 10_000,
allowExitCodes: [1],
},
);
return response.stdout.trim();
}
async trackedFiles(root) {
const response = await run("git", ["ls-files", "-z"], {
cwd: root,
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
});
return response.stdout.split("\0").filter(Boolean);
}
async scan(repository) {
const checks = [];
const defaultBranch = repository.defaultBranch || "main";
const owner = repository.owner?.login;
try {
const protection = await this.gitea.getBranchProtection(
owner,
repository.name,
defaultBranch,
);
checks.push(
result(
"default-branch-protection",
"Gitea governance",
"Default branch protection",
protection.protected ? "pass" : "warning",
protection.protected
? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.`
: `${defaultBranch} accepts unprotected direct changes.`,
{
weight: 18,
fixAction: protection.protected ? null : "protect-default-branch",
safe: false,
confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`,
},
),
);
if (protection.protected)
checks.push(
result(
"force-push",
"Gitea governance",
"Force-push protection",
protection.enableForcePush ? "warning" : "pass",
protection.enableForcePush
? "Force pushes remain enabled on the protected branch."
: "Force pushes are blocked on the protected branch.",
{ weight: 8 },
),
);
} catch (error) {
checks.push(
result(
"branch-protection-unavailable",
"Gitea governance",
"Branch protection could not be verified",
"warning",
error.message,
{ weight: 18 },
),
);
}
if (!repository.localPath) {
checks.push(
result(
"local-link",
"Local repository",
"Local working tree",
"warning",
"Link or clone this repository to validate files and local Git configuration.",
{ weight: 35 },
),
);
return this.summarize(repository, checks);
}
const root = await this.git.ensureRepository(repository.localPath);
const status = await this.git.status(root);
const tracked = await this.trackedFiles(root);
const lowerFiles = tracked.map((file) => file.toLowerCase());
const desiredRemote =
repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl;
checks.push(
result(
"origin",
"Repository identity",
"Origin matches Gitea",
sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error",
sameRemote(status.remoteUrl, desiredRemote)
? status.remoteUrl
: `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`,
{
weight: 15,
fixAction: sameRemote(status.remoteUrl, desiredRemote)
? null
: "align-origin",
safe: true,
},
),
);
checks.push(
result(
"upstream",
"Branch hygiene",
"Current branch has an upstream",
status.branch?.upstream ? "pass" : "warning",
status.branch?.upstream
? `${status.branch.head} tracks ${status.branch.upstream}.`
: `${status.branch?.head || "The current branch"} is not published or tracked.`,
{ weight: 8 },
),
);
checks.push(
result(
"working-tree",
"Branch hygiene",
"Working tree is intentional",
status.clean ? "pass" : "warning",
status.clean
? "No uncommitted changes."
: `${status.counts.changed} changed file(s) require review, commit or stash.`,
{ weight: 5 },
),
);
const [userName, userEmail, fetchPrune, pullFf, autoStash] =
await Promise.all([
this.config(root, "user.name", { local: false }),
this.config(root, "user.email", { local: false }),
this.config(root, "fetch.prune"),
this.config(root, "pull.ff"),
this.config(root, "rebase.autoStash"),
]);
checks.push(
result(
"identity",
"Commit integrity",
"Repository author identity",
userName && userEmail ? "pass" : "warning",
userName && userEmail
? `${userName} <${userEmail}>`
: "The effective Git user.name or user.email is missing.",
{ weight: 7 },
),
);
const safetyReady =
fetchPrune === "true" && pullFf === "only" && autoStash === "true";
checks.push(
result(
"local-safety",
"Local configuration",
"Safe synchronization defaults",
safetyReady ? "pass" : "warning",
safetyReady
? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled."
: "Recommended repository-local fetch, pull and autostash safeguards are incomplete.",
{
weight: 10,
fixAction: safetyReady ? null : "configure-local-safety",
safe: true,
},
),
);
const hasReadme = lowerFiles.some((file) =>
/(^|\/)readme(\.[^/]+)?$/.test(file),
);
checks.push(
result(
"readme",
"Repository documentation",
"README is versioned",
hasReadme ? "pass" : "warning",
hasReadme
? "Repository purpose and usage can be documented at the source."
: "No tracked README was found.",
{ weight: 7 },
),
);
const hasGitignore = lowerFiles.includes(".gitignore");
checks.push(
result(
"gitignore",
"Repository hygiene",
".gitignore is versioned",
hasGitignore ? "pass" : "warning",
hasGitignore
? "Generated and local-only files can be excluded centrally."
: "No tracked .gitignore was found.",
{
weight: 8,
fixAction: hasGitignore ? null : "add-gitignore",
safe: false,
confirmation:
"Create a recommended .gitignore in the working tree? It will remain uncommitted for review.",
},
),
);
const sensitive = tracked.filter(isSensitiveTrackedPath);
checks.push(
result(
"tracked-secrets",
"Security",
"No secret-shaped files are tracked",
sensitive.length ? "error" : "pass",
sensitive.length
? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.`
: "No tracked environment, private-key or credential filenames were detected.",
{ weight: 22 },
),
);
const large = [];
const candidates = tracked.slice(0, 5000);
for (
let index = 0;
index < candidates.length && large.length < 12;
index += 64
) {
const batch = candidates.slice(index, index + 64);
const stats = await Promise.all(
batch.map(async (file) => ({
file,
stat: await fs.stat(path.join(root, file)).catch(() => null),
})),
);
for (const item of stats) {
if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024)
large.push({ file: item.file, size: item.stat.size });
if (large.length >= 12) break;
}
}
checks.push(
result(
"large-files",
"Repository performance",
"No oversized tracked files",
large.length ? "warning" : "pass",
large.length
? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.`
: "No tracked files above 10 MB were found.",
{ weight: 7 },
),
);
return this.summarize(repository, checks);
}
summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce(
(sum, check) =>
sum +
(check.status === "pass"
? check.weight
: check.status === "warning"
? check.weight * 0.45
: 0),
0,
);
const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0;
return {
repository: repository.fullName,
checkedAt: new Date().toISOString(),
score,
grade:
score >= 90
? "Excellent"
: score >= 75
? "Good"
: score >= 55
? "Needs attention"
: "High risk",
checks,
summary: {
passed: checks.filter((check) => check.status === "pass").length,
warnings: checks.filter((check) => check.status === "warning").length,
errors: checks.filter((check) => check.status === "error").length,
repairable: checks.filter((check) => check.fixAction).length,
},
};
}
async repair(repository, check) {
if (!check?.fixAction)
throw new Error("This validator check has no repair action.");
const root = repository.localPath
? await this.git.ensureRepository(repository.localPath)
: null;
if (check.fixAction === "align-origin") {
return this.git.setRemoteUrl(
root,
repository.preferredCloneUrl ||
repository.cloneUrl ||
repository.sshUrl,
);
}
if (check.fixAction === "configure-local-safety") {
for (const [key, value] of [
["fetch.prune", "true"],
["pull.ff", "only"],
["rebase.autoStash", "true"],
])
await run("git", ["config", "--local", key, value], {
cwd: root,
timeout: 10_000,
});
return { configured: true };
}
if (check.fixAction === "add-gitignore") {
const target = path.join(root, ".gitignore");
const exists = await fs.stat(target).catch(() => null);
if (exists)
throw new Error(".gitignore already exists; rescan before repairing.");
await fs.writeFile(target, RECOMMENDED_GITIGNORE, {
encoding: "utf8",
flag: "wx",
});
return { created: ".gitignore" };
}
if (check.fixAction === "protect-default-branch") {
return this.gitea.createBranchProtection(
repository.owner.login,
repository.name,
repository.defaultBranch || "main",
);
}
throw new Error("Unsupported Git Validator repair action.");
}
}
module.exports = {
GitValidatorService,
RECOMMENDED_GITIGNORE,
sameRemote,
isSensitiveTrackedPath,
};
+22
View File
@@ -204,6 +204,28 @@ class GiteaService {
};
}
async createBranchProtection(owner, repo, branch) {
const target = assertBranchName(branch);
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
{
method: "POST",
body: {
rule_name: target,
branch_name: target,
enable_push: false,
enable_force_push: false,
required_approvals: 0,
dismiss_stale_approvals: true,
block_on_rejected_reviews: true,
block_on_outdated_branch: true,
},
},
)
).data;
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({
state,
+35
View File
@@ -87,6 +87,7 @@ function registerIpc({
ssh,
updates,
preflight,
gitValidator,
diagnostics,
audit,
externalTools,
@@ -841,6 +842,40 @@ function registerIpc({
return true;
});
register("git-validator:scan", async ({ fullName }) => {
const repository = await resolveRepository({ fullName });
const report = await gitValidator.scan(repository);
await diagnostics.info("git-validator.scan.completed", {
repository: repository.fullName,
score: report.score,
summary: report.summary,
});
return report;
});
register("git-validator:repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const allowed = new Set([
"align-origin",
"configure-local-safety",
"add-gitignore",
"protect-default-branch",
]);
if (!allowed.has(check?.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, check);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
return result;
});
register("troubleshooter:scan", async ({ fullName = null }) => {
const currentRepositories = await repositories.refresh();
const candidates = fullName
+100 -1
View File
@@ -180,6 +180,7 @@ const ui = {
servers: [],
serverInspection: null,
gitRecovery: null,
gitValidation: null,
diffHunks: null,
conflictState: null,
branchProtection: null,
@@ -487,6 +488,7 @@ function selectRepository(id, shouldRender = true) {
ui.branches = [];
ui.stashes = [];
ui.gitRecovery = null;
ui.gitValidation = null;
ui.branchProtection = null;
const repository = selectedRepository();
ui.selectedProfileId = selectedProfile(repository)?.id || null;
@@ -968,6 +970,34 @@ function renderRepositorySettings(repository) {
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")}${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`;
}
function renderGitValidator(repository) {
const report = ui.gitValidation;
if (!report)
return `<div class="validator-empty panel">${projectIllustration("diagnostics")}<div><div class="eyebrow">Repository assurance</div><h2>Validate Git best practices</h2><p>Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.</p><button class="button primary" data-action="git-validator-scan">${icon("shield")}Run Git Validator</button></div></div>`;
const tone =
report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger";
const safeFixes = report.checks.filter(
(check) => check.fixAction && check.safe,
);
const groups = report.checks.reduce((grouped, check) => {
(grouped[check.category] ||= []).push(check);
return grouped;
}, {});
return `<div class="validator-page"><section class="validator-hero panel ${tone}"><div class="validator-score"><strong>${report.score}</strong><span>/ 100</span></div><div><div class="eyebrow">Git assurance score</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button>${safeFixes.length ? `<button class="button primary" data-action="git-validator-repair-safe">${icon("wrench")}Apply ${safeFixes.length} safe fix${safeFixes.length === 1 ? "" : "es"}</button>` : ""}</div></section><div class="validator-groups">${Object.entries(
groups,
)
.map(
([category, checks]) =>
`<section class="panel validator-group"><div class="panel-header"><h3>${escapeHtml(category)}</h3><span class="meta">${checks.filter((check) => check.status === "pass").length}/${checks.length} passed</span></div><div class="validator-checks">${checks
.map((check) => {
const checkIndex = report.checks.indexOf(check);
return `<article class="validator-check ${check.status}"><span class="validator-check-icon">${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(check.title)}</strong><p>${escapeHtml(check.detail)}</p></div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}${check.safe ? "Fix safely" : "Review & fix"}</button>` : `<span class="status-pill ${check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
})
.join("")}</div></section>`,
)
.join("")}</div></div>`;
}
function renderRepositoryWorkspace(repository) {
const status = repository.localStatus;
const profile = selectedProfile(repository);
@@ -998,6 +1028,7 @@ function renderRepositoryWorkspace(repository) {
history: renderHistory,
deployments: renderRepositoryDeployments,
gittools: renderGitTools,
validator: renderGitValidator,
settings: renderRepositorySettings,
}[ui.repositoryTab] || renderChanges
)(repository);
@@ -1009,6 +1040,7 @@ function renderRepositoryWorkspace(repository) {
["history", "History"],
["deployments", "Deployments"],
["gittools", "Git tools"],
["validator", "Git Validator"],
["settings", "Project settings"],
]
.map(
@@ -1669,7 +1701,19 @@ app.addEventListener("click", async (event) => {
ui.repositoryTab = target.dataset.tab;
if (ui.repositoryTab === "gittools" && !ui.branches.length)
await loadGitTools(repository);
else if (ui.repositoryTab === "settings") {
else if (ui.repositoryTab === "validator" && !ui.gitValidation) {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
render();
} else if (ui.repositoryTab === "settings") {
try {
ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName,
@@ -1681,6 +1725,61 @@ app.addEventListener("click", async (event) => {
}
render();
} else render();
} else if (action === "git-validator-scan") {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast(
"Git validation complete",
`${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`,
ui.gitValidation.summary.errors ? "error" : "success",
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair") {
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
if (!check?.fixAction) return;
if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`))
return;
setLoading(true, `Repairing ${check.title}`);
try {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Git best practice repaired", check.title, "success");
} catch (error) {
showToast("Repair failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair-safe") {
const checks = (ui.gitValidation?.checks || []).filter(
(check) => check.fixAction && check.safe,
);
setLoading(true, `Applying ${checks.length} safe Git fixes…`);
let repaired = 0;
try {
for (const check of checks) {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
repaired += 1;
}
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Safe Git fixes applied", `${repaired} repaired.`, "success");
} catch (error) {
showToast("Safe repair stopped", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "toggle-favorite") {
ui.boot.state = await window.forgeflow.favoriteRepository(
repository.fullName,
+112 -1
View File
@@ -564,7 +564,7 @@
await wait(80);
snapshot();
return {
appVersion: "0.8.8-demo",
appVersion: "0.8.9-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
@@ -1461,6 +1461,117 @@
state.operations.find((item) => item.id === operationId) || null,
);
},
async gitValidatorScan(fullName) {
await wait(260);
return {
repository: fullName,
checkedAt: iso(),
score: 78,
grade: "Good",
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
checks: [
{
id: "origin",
category: "Repository identity",
title: "Origin matches Gitea",
status: "pass",
detail: "The local origin resolves to this Gitea repository.",
weight: 15,
},
{
id: "default-branch-protection",
category: "Gitea governance",
title: "Default branch protection",
status: "warning",
detail: "main accepts unprotected direct changes.",
weight: 18,
fixAction: "protect-default-branch",
safe: false,
confirmation:
"Protect main on Gitea and block direct and force pushes?",
},
{
id: "force-push",
category: "Gitea governance",
title: "Force-push protection",
status: "pass",
detail: "Force pushes are blocked.",
weight: 8,
},
{
id: "upstream",
category: "Branch hygiene",
title: "Current branch has an upstream",
status: "pass",
detail: "main tracks origin/main.",
weight: 8,
},
{
id: "working-tree",
category: "Branch hygiene",
title: "Working tree is intentional",
status: "warning",
detail: "3 changed files require review, commit or stash.",
weight: 5,
},
{
id: "identity",
category: "Commit integrity",
title: "Repository author identity",
status: "pass",
detail: "Jens <jens@example.test>",
weight: 7,
},
{
id: "local-safety",
category: "Local configuration",
title: "Safe synchronization defaults",
status: "warning",
detail: "Recommended repository-local safeguards are incomplete.",
weight: 10,
fixAction: "configure-local-safety",
safe: true,
},
{
id: "readme",
category: "Repository documentation",
title: "README is versioned",
status: "pass",
detail: "Repository documentation is tracked.",
weight: 7,
},
{
id: "gitignore",
category: "Repository hygiene",
title: ".gitignore is versioned",
status: "pass",
detail: "Generated files are excluded centrally.",
weight: 8,
},
{
id: "tracked-secrets",
category: "Security",
title: "No secret-shaped files are tracked",
status: "pass",
detail:
"No tracked environment, key or credential filenames detected.",
weight: 22,
},
{
id: "large-files",
category: "Repository performance",
title: "No oversized tracked files",
status: "pass",
detail: "No tracked files above 10 MB were found.",
weight: 7,
},
],
};
},
async gitValidatorRepair() {
await wait(180);
return { repaired: true };
},
async diagnosticsStatus() {
return {
enabled: state.preferences.diagnosticsEnabled !== false,
+221
View File
@@ -1280,6 +1280,227 @@ html[data-theme="light"] .diff-line.remove {
color: #caa7ff;
background: rgba(148, 97, 214, 0.08);
}
.validator-page {
container-type: inline-size;
padding: 18px;
display: grid;
gap: 14px;
overflow: auto;
}
.validator-empty {
min-height: 360px;
margin: 18px;
padding: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
text-align: left;
overflow: hidden;
}
.validator-empty > div:last-child {
max-width: 520px;
}
.validator-empty h2 {
margin: 5px 0 8px;
font-size: 24px;
}
.validator-empty p {
margin: 0 0 18px;
color: var(--text-muted);
line-height: 1.65;
}
.validator-hero {
position: relative;
min-height: 160px;
padding: 24px;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) minmax(180px, 260px) auto;
align-items: center;
gap: 22px;
overflow: hidden;
background:
radial-gradient(
circle at 68% 16%,
color-mix(in srgb, var(--primary) 16%, transparent),
transparent 28%
),
linear-gradient(
120deg,
var(--surface-1),
color-mix(in srgb, var(--surface-2) 84%, var(--primary-soft))
);
}
.validator-hero.success {
--validator-accent: var(--success);
}
.validator-hero.warning {
--validator-accent: var(--warning);
}
.validator-hero.danger {
--validator-accent: var(--danger);
}
.validator-score {
width: 116px;
height: 116px;
border-radius: 32px;
display: grid;
place-content: center;
text-align: center;
background: color-mix(in srgb, var(--validator-accent) 10%, var(--surface-2));
border: 1px solid color-mix(in srgb, var(--validator-accent) 42%, var(--line));
box-shadow:
inset 0 0 34px color-mix(in srgb, var(--validator-accent) 10%, transparent),
0 18px 38px rgba(0, 0, 0, 0.15);
}
.validator-score strong {
color: var(--validator-accent);
font-size: 42px;
line-height: 0.9;
letter-spacing: -0.05em;
}
.validator-score span {
margin-top: 7px;
color: var(--text-muted);
font: 700 10px/1 var(--font-mono);
}
.validator-hero h2 {
margin: 4px 0 6px;
font-size: 24px;
}
.validator-hero p {
margin: 0;
color: var(--text-muted);
}
.validator-hero .project-illustration {
width: 220px;
opacity: 0.82;
}
.validator-actions {
display: grid;
gap: 8px;
min-width: 150px;
}
.validator-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.validator-group {
overflow: hidden;
}
.validator-checks {
display: grid;
}
.validator-check {
min-height: 78px;
padding: 13px 14px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
border-top: 1px solid var(--line-soft);
transition:
background 160ms ease,
transform 160ms ease;
}
.validator-check:hover {
background: var(--surface-hover);
}
.validator-check-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
color: var(--text-muted);
background: var(--surface-2);
}
.validator-check.pass .validator-check-icon {
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.validator-check.warning .validator-check-icon {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.validator-check.error .validator-check-icon {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.validator-check strong {
display: block;
font-size: 12px;
}
.validator-check p {
margin: 4px 0 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
overflow-wrap: anywhere;
}
html[data-theme="light"] .validator-hero {
background:
radial-gradient(
circle at 68% 16%,
rgba(66, 91, 220, 0.18),
transparent 30%
),
linear-gradient(
120deg,
rgba(255, 255, 255, 0.98),
rgba(236, 243, 255, 0.96)
);
}
@media (max-width: 1180px) {
.validator-hero {
grid-template-columns: auto 1fr auto;
}
.validator-hero .project-illustration {
display: none;
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 900px) {
.validator-hero {
grid-template-columns: auto minmax(0, 1fr);
}
.validator-hero .project-illustration {
display: none;
}
.validator-actions {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 520px) {
.validator-hero {
grid-template-columns: 1fr;
}
.validator-score {
width: 92px;
height: 92px;
border-radius: 25px;
}
.validator-actions {
grid-template-columns: 1fr;
}
.validator-check {
grid-template-columns: 30px minmax(0, 1fr);
}
.validator-check > .button,
.validator-check > .status-pill {
grid-column: 2;
justify-self: start;
}
}
.empty-state {
height: 100%;
min-height: 260px;