Initial curated RepoPulse source from 2524ccc3c1c27739996dccb2b7992989f88cd26d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:52 +02:00
commit 0adf8ef5d9
90 files changed
+14315

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+55
View File
@@ -0,0 +1,55 @@
import { createServer } from 'node:http'
const now = '2026-09-14T08:00:00.000Z'
const policy = { auto_scan: true, min_health_score: 0, max_blockers: 0, require_tests: false, require_ci: false, suppressions: [] }
const guidance = {
remediation_class: 'dependency reproducibility', confidence: 'high',
why: 'A deterministic package-manager lockfile was not found for this workspace.',
recommended_action: 'Commit the package-manager lockfile used by the build.',
priority: 800, priority_rationale: 'Blocker signal; retained rule evidence.',
}
const finding = { code: 'dependency.no-lockfile', category: 'Dependencies', severity: 'blocker', message: 'No lockfile was retained for the declared dependencies.', path: 'package.json', line: 4, deduction: 8, fingerprint: 'finding-alpha', suppressed: false, guidance }
const report = {
id: 'scan-alpha', repository_path: '/scan/Alpha', repository_name: 'Alpha',
source: { origin: 'gitea', provider: 'gitea', repository_id: 'gitea:1', repository_full_name: 'Jens/Alpha', branch: 'main', requested_revision: 'a'.repeat(40), actual_revision: 'a'.repeat(40), trigger: 'manual' },
policy, scanned_at: now, duration_ms: 42, metrics: { files: 5, source_files: 2, lines_of_code: 20, test_files: 1, todo: 0, fixme: 0, hack: 0, xxx: 0, dependency_manifests: 1, declared_dependencies: 2, large_files: 0, large_source_files: 0 },
languages: { TypeScript: 20 }, category_scores: { Architecture: 100, Testing: 100, Maintainability: 100, Documentation: 100, Dependencies: 92, 'Repository hygiene': 100, 'Security basics': 100 },
health_score: 88, release_readiness: 'BLOCKED', findings: [finding],
tests: { frameworks: ['Vitest'], coverage_artifacts: [], ci_workflows: ['.gitea/workflows/validate.yml'], ci_validation_workflows: ['.gitea/workflows/validate.yml'], ci_build_workflows: ['.gitea/workflows/validate.yml'], ci_deploy_only_workflows: [], coverage_evidence: ['.gitea/workflows/validate.yml'], test_source_ratio: 50 },
git: { available: true, commit_count: 3, contributor_count: 1, latest_commit_at: now, tracked_files: 5, renamed_files: [] }, dependencies: { manifests: ['package.json'], lockfiles: [], declared_dependencies: 2, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [], included_requirements: [] },
architecture: { nodes: [{ id: 'src/app.ts', label: 'app.ts', path: 'src/app.ts', language: 'TypeScript', inbound: 0, outbound: 0, visible_inbound: 0, visible_outbound: 0, hotspot_score: 10 }], edges: [], clusters: { src: 1 }, truncated: false, total_nodes: 1, total_edges: 0 }, hotspots: [],
release_gate: { status: 'BLOCKED', checks: [{ key: 'security', label: 'Security basics', state: 'pass', detail: 'No secret findings' }, { key: 'dependencies', label: 'Dependency hygiene', state: 'block', detail: 'Lockfile missing' }] },
coverage: { limits: { max_files: 10000, max_text_bytes_per_file: 2000000, max_findings: 2000, max_secret_findings: 20 }, discovered_files: 5, scanned_files: 5, skipped_files_by_budget: 0, skipped_files_by_size: 0, text_bytes_scanned: 256, truncated: false, notes: [] }, compacted: false,
}
function repository(id, fullName, readiness = 'READY') {
const name = fullName.split('/')[1]
const blocked = readiness === 'BLOCKED'
return { id, provider: 'gitea', provider_repository_id: id.slice(-1), owner: 'Jens', name, full_name: fullName, description: `${name} fixture repository`, html_url: null, clone_url: null, default_branch: 'main', private: true, archived: false, available: true, latest_revision: 'a'.repeat(40), latest_revision_at: now, last_synced_at: now, sync_error: null, state: 'current', stale: false, active_job_id: null, last_scan_id: id === 'gitea:1' ? report.id : null, last_scan_at: id === 'gitea:1' ? now : null, last_scan_health: id === 'gitea:1' ? 88 : 96, last_scan_readiness: readiness, last_scan_revision: 'a'.repeat(40), last_scan_blockers: blocked ? 1 : 0, last_scan_warnings: 0, last_scan_primary_signal: blocked ? { code: finding.code, category: finding.category, severity: finding.severity, message: finding.message, count: 1, guidance } : null, last_scan_health_delta: 0, last_scan_added_findings: 0, last_scan_resolved_findings: 0, last_scan_priority: blocked ? 800 : 0, last_scan_priority_rationale: blocked ? guidance.priority_rationale : null, policy }
}
const repositories = [
repository('gitea:1', 'Jens/Alpha', 'BLOCKED'),
repository('gitea:2', 'Jens/Beta'),
...Array.from({ length: 23 }, (_unused, index) => repository(`gitea:${index + 3}`, `Jens/Gamma-${String(index + 1).padStart(2, '0')}`)),
]
let jobPolls = 0
const job = { id: 'job-alpha', repository_id: 'gitea:1', status: 'queued', trigger: 'manual', requested_revision: 'a'.repeat(40), actual_revision: null, phase: 'queued', progress: 0, message: 'Waiting for an available worker', created_at: now, started_at: null, updated_at: now, finished_at: null, duration_ms: null, scan_id: null, error_code: null, error_detail: null, attempt: 1, deduplicated: false }
function send(response, status, body, type = 'application/json') { response.writeHead(status, { 'Content-Type': type }); response.end(type === 'application/json' ? JSON.stringify(body) : body) }
createServer((request, response) => {
const url = new URL(request.url, 'http://127.0.0.1:8001')
const path = decodeURIComponent(url.pathname)
if (path === '/health') return send(response, 200, { status: 'ok' })
if (path === '/api/v1/meta') return send(response, 200, { name: 'RepoPulse', version: '1.5.0', stage: 'v1.5.0', scan_root_restricted: true, scan_root: '/scan', max_concurrent_scans: 1, requested_max_concurrent_scans: 1, repository_count: repositories.length, repository_states: { current: 2 }, current_repositories: 2, stale_repositories: 0, queue_depth: 0, active_jobs: 0, completed_jobs: 1, failed_jobs: 0, worker_heartbeat: now, gitea: { configured: true, url: 'http://gitea.local', last_sync: { synced_at: now } }, storage: { database_bytes: 1024, journal_bytes: 0, mirror_count: 2, mirror_cache_bytes: 2048, worktree_count: 0, scan_count: 1, compacted_scan_count: 0, oldest_scan_at: now, newest_scan_at: now, history_per_repository: 200, full_reports_per_repository: 30, report_retention_days: 365 }, database_recovery: { status: 'healthy' }, mutation_authentication: 'perimeter_required' })
if (path === '/api/v1/repositories' && request.method === 'GET') return send(response, 200, repositories)
if (path === '/api/v1/repositories/sync' && request.method === 'POST') return send(response, 200, { provider: 'gitea', discovered: 2, created: 0, updated: 2, unavailable: 0, errors: [], partial: false, branch_failures: 0, listing_complete: true, synced_at: now })
if (path === '/api/v1/repositories/gitea:1' && request.method === 'GET') return send(response, 200, { ...repositories[0], latest_scan: report })
if (path === '/api/v1/repositories/gitea:2' && request.method === 'GET') return send(response, 200, { ...repositories[1], latest_scan: null })
if (path === '/api/v1/repositories/gitea:1/scans' && request.method === 'GET') return send(response, 200, [{ id: report.id, repository_id: 'gitea:1', scanned_at: now, branch: 'main', requested_revision: report.source.requested_revision, actual_revision: report.source.actual_revision, trigger: 'manual', health_score: 88, release_readiness: 'BLOCKED', findings: 1, duration_ms: 42 }])
if (path === '/api/v1/repositories/gitea:2/scans' && request.method === 'GET') return send(response, 200, [])
if (path.endsWith('/trend')) return send(response, 200, [{ scan_id: report.id, scanned_at: now, health_score: 88, findings: 1, blockers: 1, warnings: 0, debt_markers: 0 }])
if (path === '/api/v1/scans/scan-alpha/comparison') return send(response, 200, { current_scan_id: report.id, previous_scan_id: null, health_score_delta: null, category_score_deltas: {}, metric_deltas: {}, added_findings: [], resolved_findings: [] })
if (path === '/api/v1/repositories/gitea:1/scans' && request.method === 'POST') { jobPolls = 0; return send(response, 202, job) }
if (path === '/api/v1/scan-jobs/job-alpha') { jobPolls += 1; if (jobPolls < 2) return send(response, 200, { ...job, status: 'scanning', phase: 'scanning', progress: 40, message: 'Running deterministic repository analysis' }); return send(response, 200, { ...job, status: 'completed', phase: 'completed', progress: 100, message: 'Scan completed', scan_id: report.id, actual_revision: report.source.actual_revision, finished_at: now, duration_ms: 42 }) }
if (path === '/api/v1/repositories/gitea:1/scans/scan-alpha') return send(response, 200, report)
if (path === '/api/v1/scans/scan-alpha/export') return send(response, 200, JSON.stringify(report), 'application/json')
return send(response, 404, { detail: `Unhandled mock endpoint: ${path}` })
}).listen(8001, '127.0.0.1')
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test'
test('portfolio workflow covers navigation, remediation, a scan lifecycle and export', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: /portfolio signal/i })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Remediation Center' })).toBeVisible()
await expect(page.getByText('A deterministic package-manager lockfile was not found for this workspace.')).toBeVisible()
await page.locator('.remediation-center').getByRole('button', { name: /Jens\/Alpha/ }).click()
await expect(page.getByRole('heading', { name: 'Jens/Alpha' })).toBeVisible()
await page.getByRole('link', { name: 'Dashboard', exact: true }).click()
await page.getByRole('button', { name: /open repository explorer/i }).click()
await expect(page.getByText('25 matching 25 repositories')).toBeVisible()
await page.getByRole('button', { name: 'Visual grid' }).click()
await page.getByRole('button', { name: 'Pin Jens/Alpha' }).click()
await expect(page.getByRole('button', { name: 'Remove Jens/Alpha from pinned repositories' })).toBeVisible()
await page.getByRole('button', { name: 'Table' }).click()
await expect(page.getByText('Page 1 / 2')).toBeVisible()
await page.getByRole('button', { name: 'Next' }).click()
await expect(page.getByText('Page 2 / 2')).toBeVisible()
await page.getByLabel('Filter by readiness').selectOption('blocked')
await expect(page.getByText('1 matching 25 repositories')).toBeVisible()
await page.getByRole('link', { name: 'Open Jens/Alpha' }).click()
await expect(page.getByRole('heading', { name: 'Jens/Alpha' })).toBeVisible()
await page.getByRole('link', { name: /findings/i }).click()
await expect(page.getByText('No lockfile was retained for the declared dependencies.')).toBeVisible()
await page.getByRole('link', { name: /architecture/i }).click()
await expect(page.getByText('Local import and dependency topology')).toBeVisible()
await page.getByRole('link', { name: /history/i }).click()
await expect(page.getByText('Repository health over time')).toBeVisible()
await page.getByRole('button', { name: 'Scan now' }).click()
await expect(page.getByText('Repository analysis completed.')).toBeVisible({ timeout: 10_000 })
await page.getByText('Reports').click()
const download = page.waitForEvent('download')
await page.getByRole('button', { name: 'Export JSON' }).click()
await expect(download).resolves.toBeTruthy()
await page.keyboard.press('Control+k')
await page.getByPlaceholder('Search repositories…').fill('Beta')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
await expect(page.getByRole('heading', { name: 'Jens/Beta', exact: true })).toBeVisible()
})
+11
View File
@@ -0,0 +1,11 @@
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const vite = fileURLToPath(new URL('../node_modules/vite/bin/vite.js', import.meta.url))
const child = spawn(process.execPath, [vite, '--host', '127.0.0.1', '--port', '4173'], {
cwd: new URL('..', import.meta.url),
env: { ...process.env, REPOPULSE_API_PROXY: 'http://127.0.0.1:8001' },
stdio: 'inherit',
})
child.on('exit', (code) => process.exit(code ?? 0))
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="RepoPulse is a premium repository intelligence cockpit for portfolio health, scan operations and architecture signals." />
<meta name="theme-color" content="#0d2130" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="/site.webmanifest" />
<title>RepoPulse — Portfolio intelligence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8000/health;
}
location / {
try_files $uri /index.html;
}
}
+3336
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "repopulse-frontend",
"private": true,
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"@xyflow/react": "^12.8.5",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.2",
"jsdom": "^30.0.1",
"typescript": "^5.9.2",
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
fullyParallel: false,
workers: 6,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{ name: '1920x1080', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } } },
{ name: '1680x900', use: { ...devices['Desktop Chrome'], viewport: { width: 1680, height: 900 } } },
{ name: '1440x900', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } },
{ name: '1366x768', use: { ...devices['Desktop Chrome'], viewport: { width: 1366, height: 768 } } },
{ name: '1024x768', use: { ...devices['Desktop Chrome'], viewport: { width: 1024, height: 768 } } },
{ name: '390x844', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } } },
],
webServer: [
{
command: 'node e2e/mock-api.mjs',
url: 'http://127.0.0.1:8001/health',
reuseExistingServer: false,
timeout: 15_000,
},
{
command: 'node e2e/start-vite.mjs',
url: 'http://127.0.0.1:4173',
reuseExistingServer: false,
timeout: 30_000,
},
],
})
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 48" role="img" aria-label="RepoPulse Repository Intelligence">
<rect width="48" height="48" rx="12" fill="#e6f4f4"/>
<path d="M10 14h8l3.1 3H38M10 34h8l3.1-3H38M18.4 24h3.2l2-4.5 3.3 9 2-4.5H38" fill="none" stroke="#166d70" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="10" cy="14" r="2.7" fill="#166d70"/><circle cx="10" cy="34" r="2.7" fill="#166d70"/><circle cx="38" cy="24" r="2.7" fill="#166d70"/>
<text x="62" y="22" fill="#102331" font-family="Arial,Helvetica,sans-serif" font-size="20" font-weight="700" letter-spacing="-.5">RepoPulse</text>
<text x="62" y="36" fill="#567080" font-family="Arial,Helvetica,sans-serif" font-size="8" font-weight="700" letter-spacing="1.4">REPOSITORY INTELLIGENCE</text>
</svg>

After

Width:  |  Height:  |  Size: 818 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 48" role="img" aria-label="RepoPulse Repository Intelligence">
<rect width="48" height="48" rx="12" fill="#0d2130"/>
<path d="M10 14h8l3.1 3H38M10 34h8l3.1-3H38M18.4 24h3.2l2-4.5 3.3 9 2-4.5H38" fill="none" stroke="#91ebe0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="10" cy="14" r="2.7" fill="#91ebe0"/><circle cx="10" cy="34" r="2.7" fill="#91ebe0"/><circle cx="38" cy="24" r="2.7" fill="#91ebe0"/>
<text x="62" y="22" fill="#eff7ff" font-family="Arial,Helvetica,sans-serif" font-size="20" font-weight="700" letter-spacing="-.5">RepoPulse</text>
<text x="62" y="36" fill="#91a5bb" font-family="Arial,Helvetica,sans-serif" font-size="8" font-weight="700" letter-spacing="1.4">REPOSITORY INTELLIGENCE</text>
</svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M5 8.5h7.1l2.2 2.2H27M5 23.5h7.1l2.2-2.2H27M12.4 16h2.5l1.55-3.4 2.15 6.8 1.4-3.4H27" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="5" cy="8.5" r="2" fill="currentColor"/><circle cx="5" cy="23.5" r="2" fill="currentColor"/><circle cx="27" cy="16" r="2" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0d2130"/>
<path d="M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="#91ebe0" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="9.5" r="2" fill="#91ebe0"/><circle cx="7" cy="22.5" r="2" fill="#91ebe0"/><circle cx="25" cy="16" r="2" fill="#91ebe0"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0d2130"/>
<path d="M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="#91ebe0" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="9.5" r="2" fill="#91ebe0"/><circle cx="7" cy="22.5" r="2" fill="#91ebe0"/><circle cx="25" cy="16" r="2" fill="#91ebe0"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

+9
View File
@@ -0,0 +1,9 @@
{
"name": "RepoPulse — Repository Intelligence",
"short_name": "RepoPulse",
"description": "Portfolio-level repository intelligence from deterministic scans.",
"display": "standalone",
"background_color": "#07111c",
"theme_color": "#0d2130",
"icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }]
}
+128
View File
@@ -0,0 +1,128 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import { api, type ScanJob } from './api'
import { testComparison, testDetail, testMeta, testRepository, testScans, testTrend } from './test-fixtures'
const repository = testRepository()
const secondRepository = testRepository('gitea:65', 'Jens/AgentTestLab', { last_scan_health: 93, last_scan_readiness: 'READY' })
const queuedJob: ScanJob = {
id: 'job-1',
repository_id: repository.id,
status: 'queued',
trigger: 'manual',
requested_revision: 'revision-1',
actual_revision: null,
phase: 'queued',
progress: 0,
message: 'Analysis queued',
created_at: '2026-09-12T20:00:00Z',
started_at: null,
updated_at: '2026-09-12T20:00:00Z',
finished_at: null,
duration_ms: null,
scan_id: null,
error_code: null,
error_detail: null,
attempt: 0,
deduplicated: false,
}
let repositoriesMock: ReturnType<typeof vi.spyOn>
beforeEach(() => {
window.history.replaceState({}, '', '/repositories')
window.localStorage.clear()
Object.defineProperty(window, 'scrollTo', { configurable: true, value: vi.fn() })
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => { callback(0); return 0 } })
repositoriesMock = vi.spyOn(api, 'repositories').mockResolvedValue([repository, secondRepository])
vi.spyOn(api, 'meta').mockResolvedValue(testMeta)
vi.spyOn(api, 'repository').mockImplementation(async (id) => id === secondRepository.id ? testDetail(secondRepository) : testDetail(repository))
vi.spyOn(api, 'repositoryScans').mockResolvedValue(testScans)
vi.spyOn(api, 'repositoryTrend').mockResolvedValue(testTrend)
vi.spyOn(api, 'comparison').mockResolvedValue(testComparison)
vi.spyOn(api, 'scanJob').mockResolvedValue(queuedJob)
vi.spyOn(api, 'enqueueRepositoryScan').mockResolvedValue(queuedJob)
vi.spyOn(api, 'exportReport').mockResolvedValue('<html>report</html>')
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('RepoPulse workspace shell', () => {
it('searches, filters and opens a repository detail route', async () => {
render(<App />)
await screen.findByRole('heading', { name: /Repositories/ })
fireEvent.change(screen.getByPlaceholderText('Search name, owner or full name'), { target: { value: 'RepoPulse' } })
expect(screen.getByRole('region', { name: 'Repository filters' }).textContent).toContain('1 matching 2 repositories')
fireEvent.change(screen.getByRole('combobox', { name: 'Filter by readiness' }), { target: { value: 'blocked' } })
expect(screen.getByRole('link', { name: 'Open Jens/RepoPulse' })).toBeTruthy()
fireEvent.click(screen.getByRole('link', { name: 'Open Jens/RepoPulse' }))
expect(await screen.findByRole('heading', { name: 'Jens/RepoPulse' })).toBeTruthy()
expect(window.location.pathname).toBe('/repositories/gitea%3A64/overview')
expect(screen.getByRole('button', { name: 'Scan now' })).toBeTruthy()
})
it('persists pinned repositories and opens a recent quick-switch selection', async () => {
render(<App />)
await screen.findByRole('heading', { name: /Repositories/ })
fireEvent.click(screen.getByRole('button', { name: 'Pin Jens/RepoPulse' }))
await waitFor(() => expect(window.localStorage.getItem('repopulse:pinned-repositories')).toContain('gitea:64'))
fireEvent.click(document.querySelector('.shortcut-button') as HTMLElement)
const switcher = await screen.findByPlaceholderText('Search repositories…')
fireEvent.change(switcher, { target: { value: 'AgentTestLab' } })
fireEvent.keyDown(window, { key: 'Enter' })
expect(await screen.findByRole('heading', { name: 'Jens/AgentTestLab' })).toBeTruthy()
expect(window.location.pathname).toBe('/repositories/gitea%3A65/overview')
expect(window.localStorage.getItem('repopulse:recent-repositories')).toContain('gitea:65')
})
it('exposes compact scan and report actions on detail', async () => {
window.history.replaceState({}, '', '/repositories/gitea%3A64/overview')
render(<App />)
await screen.findByRole('button', { name: 'Scan now' })
fireEvent.click(screen.getByText('Reports', { exact: true }))
expect(screen.getByRole('button', { name: 'Export HTML' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Export JSON' })).toBeTruthy()
fireEvent.click(screen.getByText('More', { exact: true }))
expect(screen.getByRole('button', { name: 'Force rescan' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'View scan runs' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Advanced info' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Scan now' }))
await waitFor(() => expect(api.enqueueRepositoryScan).toHaveBeenCalledWith('gitea:64', false))
})
it('shows an actionable empty detail state for an unscanned repository', async () => {
const emptyRepository = testRepository('gitea:66', 'Jens/EmptyRepo', {
state: 'never_scanned',
last_scan_id: null,
last_scan_at: null,
last_scan_health: null,
last_scan_readiness: null,
last_scan_revision: null,
})
repositoriesMock.mockResolvedValue([emptyRepository])
vi.mocked(api.repository).mockResolvedValue({ ...emptyRepository, latest_scan: null })
window.history.replaceState({}, '', '/repositories/gitea%3A66/overview')
render(<App />)
expect(await screen.findByText('Jens/EmptyRepo has no persisted scan yet.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Scan repository' })).toBeTruthy()
})
})
+179
View File
@@ -0,0 +1,179 @@
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, type AppMeta, type RepositorySummary, type ScanComparison, type ScanJob, type ScanReport, type ScanSummary, type TrendPoint } from './api'
import { filterRepositories, orderSwitcherRepositories, paginateRepositories, readinessLabel, repositoryReadiness, sortRepositories, stateLabel, type CatalogFilters, type CatalogSort, type CatalogSortKey, type Readiness } from './catalog'
import { ArchitectureView } from './components/ArchitectureView'
import { BrandLockup, BrandMark } from './components/Brand'
import { DashboardView } from './components/DashboardView'
import { DependenciesView } from './components/DependenciesView'
import { FindingsView } from './components/FindingsView'
import { HistoryView } from './components/HistoryView'
import { HotspotsView } from './components/HotspotsView'
import { OverviewView } from './components/OverviewView'
import { RepositoryVisual } from './components/RepositoryVisual'
import { ScanRunsView } from './components/ScanRunsView'
import { DETAIL_VIEWS, parseRoute, routePath, type DetailView, type Route } from './navigation'
const ACTIVE_JOB_STATES: ScanJob['status'][] = ['queued', 'acquiring_source', 'scanning', 'analyzing', 'persisting']
const PINNED_STORAGE_KEY = 'repopulse:pinned-repositories'
const RECENT_STORAGE_KEY = 'repopulse:recent-repositories'
const CATALOG_VIEW_STORAGE_KEY = 'repopulse:catalog-view'
const DEFAULT_FILTERS: CatalogFilters = { query: '', state: 'all', readiness: 'all', owner: 'all', branch: 'all', visibility: 'all', archived: 'active' }
function readIdList(key: string) {
try {
const parsed = JSON.parse(window.localStorage.getItem(key) ?? '[]')
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : []
} catch { return [] }
}
function readCatalogView(): 'table' | 'grid' {
try { return window.localStorage.getItem(CATALOG_VIEW_STORAGE_KEY) === 'grid' ? 'grid' : 'table' } catch { return 'table' }
}
function writeIdList(key: string, ids: string[]) { try { window.localStorage.setItem(key, JSON.stringify(ids)) } catch { /* preferences are best effort */ } }
function shortRevision(revision: string | null) { return revision ? revision.slice(0, 10) : 'not acquired' }
function statusClass(value: string) { return value.replace(/_/g, '-') }
function formatDate(value: string | null) { if (!value) return 'Never'; const date = new Date(value); return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) }
function formatRelativeDate(value: string | null) { if (!value) return 'Never scanned'; const difference = Date.now() - new Date(value).getTime(); if (Number.isNaN(difference)) return 'Unknown'; if (difference < 3_600_000) return `${Math.max(1, Math.floor(difference / 60_000))}m ago`; if (difference < 86_400_000) return `${Math.floor(difference / 3_600_000)}h ago`; return `${Math.floor(difference / 86_400_000)}d ago` }
function downloadText(filename: string, content: string, mime: string) { const url = URL.createObjectURL(new Blob([content], { type: mime })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); window.setTimeout(() => { anchor.remove(); URL.revokeObjectURL(url) }, 0) }
function reportFilename(repositoryName: string, format: 'html' | 'json') { const safeName = repositoryName.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]/g, '_').replace(/[ .]+$/g, '').slice(0, 100).replace(/[ .]+$/g, '') || 'repository'; return `repopulse-${safeName}.${format}` }
function detailViewLabel(view: DetailView, report: ScanReport | null) { if (view === 'overview') return 'Overview'; if (view === 'findings') return `Findings (${report?.findings.filter((finding) => !finding.suppressed).length ?? 0})`; if (view === 'architecture') return `Architecture (${report?.architecture.nodes.length ?? 0})`; if (view === 'hotspots') return `Hotspots (${report?.hotspots.length ?? 0})`; if (view === 'dependencies') return 'Dependencies'; if (view === 'history') return 'History'; return 'Scan runs' }
export default function App() {
const [route, setRoute] = useState<Route>(() => parseRoute(window.location.pathname))
const [repositories, setRepositories] = useState<RepositorySummary[]>([])
const [catalogLoading, setCatalogLoading] = useState(true)
const [selectedDetailsLoading, setSelectedDetailsLoading] = useState(false)
const [scanSummaries, setScanSummaries] = useState<ScanSummary[]>([])
const [report, setReport] = useState<ScanReport | null>(null)
const [comparison, setComparison] = useState<ScanComparison | null>(null)
const [trend, setTrend] = useState<TrendPoint[]>([])
const [path, setPath] = useState('')
const [job, setJob] = useState<ScanJob | null>(null)
const [meta, setMeta] = useState<AppMeta | null>(null)
const [standaloneReport, setStandaloneReport] = useState<ScanReport | null>(null)
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readIdList(PINNED_STORAGE_KEY))
const [recentIds, setRecentIds] = useState<string[]>(() => readIdList(RECENT_STORAGE_KEY))
const [catalogFilters, setCatalogFilters] = useState<CatalogFilters>(DEFAULT_FILTERS)
const [catalogSort, setCatalogSort] = useState<CatalogSort>({ key: 'name', direction: 'asc' })
const [catalogPage, setCatalogPage] = useState(1)
const [catalogPageSize, setCatalogPageSize] = useState(24)
const [catalogView, setCatalogView] = useState<'table' | 'grid'>(readCatalogView)
const [busy, setBusy] = useState(false)
const [catalogBusy, setCatalogBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [switcherOpen, setSwitcherOpen] = useState(false)
const [switcherQuery, setSwitcherQuery] = useState('')
const [switcherIndex, setSwitcherIndex] = useState(0)
const [advancedOpen, setAdvancedOpen] = useState(false)
const switcherInputRef = useRef<HTMLInputElement>(null)
const selectedId = route.repositoryId ?? null
const selectedRepository = useMemo(() => repositories.find((repository) => repository.id === selectedId) ?? null, [repositories, selectedId])
const activeJob = job && job.repository_id === selectedId && ACTIVE_JOB_STATES.includes(job.status) ? job : null
const navigate = useCallback((next: Route, replace = false) => { const href = routePath(next); if (window.location.pathname !== href) window.history[replace ? 'replaceState' : 'pushState']({}, '', href); setRoute(next); setAdvancedOpen(false); window.scrollTo?.({ top: 0, behavior: 'auto' }) }, [])
const handleNavigation = useCallback((event: React.MouseEvent<HTMLAnchorElement>, next: Route) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigate(next) }, [navigate])
const refreshCatalog = useCallback(async () => { const next = await api.repositories(); setRepositories(next); return next }, [])
const refreshMeta = useCallback(async () => { try { setMeta(await api.meta()) } catch { /* catalog navigation must remain usable */ } }, [])
useEffect(() => {
const onPopState = () => setRoute(parseRoute(window.location.pathname))
window.addEventListener('popstate', onPopState)
void (async () => { setCatalogLoading(true); try { await refreshCatalog() } catch { try { const legacy = await api.recent(); setStandaloneReport(legacy[0] ?? null); setPath(legacy[0]?.repository_path ?? ''); setError(legacy.length ? 'Repository catalog is unavailable; showing the last local report in Settings.' : 'Could not load the repository catalog.') } catch (err) { setError(err instanceof Error ? err.message : 'Could not load the repository catalog.') } } finally { setCatalogLoading(false) } })()
void refreshMeta()
return () => window.removeEventListener('popstate', onPopState)
}, [refreshCatalog, refreshMeta])
useEffect(() => { document.title = route.page === 'dashboard' ? 'RepoPulse — Portfolio intelligence' : 'RepoPulse — Repository intelligence' }, [route.page])
useEffect(() => {
if (!selectedId) { setReport(null); setScanSummaries([]); setTrend([]); setComparison(null); setSelectedDetailsLoading(false); return }
let live = true
setSelectedDetailsLoading(true); setReport(null); setComparison(null); setTrend([])
void Promise.all([api.repository(selectedId), api.repositoryScans(selectedId), api.repositoryTrend(selectedId)]).then(([detail, summaries, nextTrend]) => { if (!live) return; setScanSummaries(summaries); setTrend(nextTrend); setReport(detail.latest_scan); setError(null); if (detail.latest_scan?.repository_path) setPath(detail.latest_scan.repository_path) }).catch((err) => { if (live) setError(err instanceof Error ? err.message : 'Could not load repository details.') }).finally(() => { if (live) setSelectedDetailsLoading(false) })
return () => { live = false }
}, [selectedId])
useEffect(() => { if (!report) { setComparison(null); return }; let live = true; void api.comparison(report.id).then((next) => { if (live) setComparison(next) }).catch(() => { if (live) setComparison(null) }); return () => { live = false } }, [report])
useEffect(() => { if (!selectedRepository?.active_job_id || job?.id === selectedRepository.active_job_id) return; let live = true; void api.scanJob(selectedRepository.active_job_id).then((next) => { if (live) setJob(next) }).catch(() => undefined); return () => { live = false } }, [job?.id, selectedRepository])
useEffect(() => {
const jobId = activeJob?.id
if (!jobId || !selectedId) return
let live = true
let failures = 0
let timer: number | undefined
const poll = async () => {
try {
const next = await api.scanJob(jobId)
if (!live) return
failures = 0
if (next.status === 'completed' && next.scan_id) {
const nextReport = await api.repositoryScan(next.repository_id, next.scan_id)
if (!live) return
const [summaries, nextTrend] = await Promise.all([api.repositoryScans(next.repository_id), api.repositoryTrend(next.repository_id)])
if (!live) return
await refreshCatalog()
if (!live) return
setJob(next)
setReport(nextReport)
setScanSummaries(summaries)
setTrend(nextTrend)
setNotice(next.deduplicated ? 'The selected revision was already analyzed; showing the existing report.' : 'Repository analysis completed.')
return
}
setJob(next)
if (next.status === 'failed') { setError(next.error_detail || 'Repository analysis failed.'); return }
} catch (err) {
failures += 1
if (!live) return
if (failures >= 5) { setError(err instanceof Error ? `${err.message} Progress polling paused after five failed attempts.` : 'Progress polling paused after five failed attempts.'); return }
}
if (live) timer = window.setTimeout(() => void poll(), 1500)
}
void poll()
return () => { live = false; if (timer) window.clearTimeout(timer) }
}, [activeJob?.id, refreshCatalog, selectedId])
useEffect(() => { if (selectedId) setRecentIds((current) => [selectedId, ...current.filter((id) => id !== selectedId)].slice(0, 8)) }, [selectedId])
useEffect(() => writeIdList(PINNED_STORAGE_KEY, pinnedIds), [pinnedIds])
useEffect(() => writeIdList(RECENT_STORAGE_KEY, recentIds), [recentIds])
useEffect(() => { try { window.localStorage.setItem(CATALOG_VIEW_STORAGE_KEY, catalogView) } catch { /* preference only */ } }, [catalogView])
const filteredRepositories = useMemo(() => filterRepositories(repositories, catalogFilters), [catalogFilters, repositories])
const sortedRepositories = useMemo(() => sortRepositories(filteredRepositories, catalogSort), [catalogSort, filteredRepositories])
const catalogWindow = useMemo(() => paginateRepositories(sortedRepositories, catalogPage, catalogPageSize), [catalogPage, catalogPageSize, sortedRepositories])
const ownerOptions = useMemo(() => [...new Set(repositories.map((repository) => repository.owner))].sort(), [repositories])
const branchOptions = useMemo(() => [...new Set(repositories.map((repository) => repository.default_branch))].sort(), [repositories])
const switcherRepositories = useMemo(() => orderSwitcherRepositories(repositories, pinnedIds, recentIds, switcherQuery), [pinnedIds, recentIds, repositories, switcherQuery])
const currentView = route.view ?? 'overview'
useEffect(() => { if (catalogPage !== catalogWindow.page) setCatalogPage(catalogWindow.page) }, [catalogPage, catalogWindow.page])
useEffect(() => { if (switcherOpen) { setSwitcherIndex(0); window.requestAnimationFrame(() => switcherInputRef.current?.focus()) } }, [switcherOpen])
useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); setSwitcherOpen(true); return }; if (!switcherOpen) return; if (event.key === 'Escape') { event.preventDefault(); setSwitcherOpen(false) } else if (event.key === 'ArrowDown') { event.preventDefault(); setSwitcherIndex((index) => Math.min(index + 1, Math.max(0, switcherRepositories.length - 1))) } else if (event.key === 'ArrowUp') { event.preventDefault(); setSwitcherIndex((index) => Math.max(0, index - 1)) } else if (event.key === 'Enter' && switcherRepositories[switcherIndex]) { event.preventDefault(); selectRepository(switcherRepositories[switcherIndex]); setSwitcherOpen(false) } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown) }, [switcherIndex, switcherOpen, switcherRepositories])
const selectRepository = (repository: RepositorySummary) => { setError(null); setNotice(null); navigate({ page: 'repositories', repositoryId: repository.id, view: 'overview' }) }
const togglePinned = (repositoryId: string) => setPinnedIds((current) => current.includes(repositoryId) ? current.filter((id) => id !== repositoryId) : [repositoryId, ...current])
const exploreReadiness = (readiness: Readiness) => { setCatalogFilters({ ...DEFAULT_FILTERS, readiness }); setCatalogPage(1); navigate({ page: 'repositories' }) }
const updateCatalogFilter = <K extends keyof CatalogFilters>(key: K, value: CatalogFilters[K]) => { setCatalogFilters((current) => ({ ...current, [key]: value })); setCatalogPage(1) }
const setCatalogSortKey = (key: CatalogSortKey) => { setCatalogSort((current) => current.key === key ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } : { key, direction: key === 'name' || key === 'state' ? 'asc' : 'desc' }); setCatalogPage(1) }
const analyzeRepository = async (repository: RepositorySummary, force = false) => { try { setBusy(true); setError(null); setNotice(null); const next = await api.enqueueRepositoryScan(repository.id, force); setJob(next); setNotice(next.deduplicated ? 'This revision is already in the analysis history.' : 'Analysis queued. Progress will update here.'); if (next.status === 'completed' && next.scan_id) setReport(await api.repositoryScan(repository.id, next.scan_id)) } catch (err) { setError(err instanceof Error ? err.message : 'Could not queue repository analysis.') } finally { setBusy(false) } }
const syncCatalog = async () => { try { setCatalogBusy(true); setError(null); const result = await api.syncRepositories(); await Promise.all([refreshCatalog(), refreshMeta()]); setNotice(result.partial ? `Gitea catalog partially synchronized: ${result.discovered} resolved, ${result.branch_failures ?? 0} branch lookup failure(s). Existing repository evidence was preserved.` : `Gitea catalog synchronized: ${result.discovered} repositories discovered.`) } catch (err) { setError(err instanceof Error ? err.message : 'Gitea catalog synchronization failed.') } finally { setCatalogBusy(false) } }
const scanLocal = async (event: FormEvent) => { event.preventDefault(); try { setBusy(true); setError(null); setNotice(null); const next = await api.scan(path); setStandaloneReport(next); setPath(next.repository_path); await refreshCatalog(); setNotice(`Local analysis completed in ${next.duration_ms} ms. The result is available in Settings.`); navigate({ page: 'settings' }) } catch (err) { setError(err instanceof Error ? err.message : 'Local repository analysis failed.') } finally { setBusy(false) } }
const selectScan = async (summary: ScanSummary) => { try { setBusy(true); setError(null); setReport(await api.repositoryScan(summary.repository_id, summary.id)); navigate({ page: 'repositories', repositoryId: summary.repository_id, view: 'overview' }) } catch (err) { setError(err instanceof Error ? err.message : 'Could not load the selected scan.') } finally { setBusy(false) } }
const exportReport = async (format: 'html' | 'json') => { if (!report) return; try { downloadText(reportFilename(report.repository_name, format), await api.exportReport(report.id, format), format === 'html' ? 'text/html' : 'application/json') } catch (err) { setError(err instanceof Error ? err.message : 'Report export failed.') } }
const renderGlobalNav = () => <nav className="global-nav" aria-label="Primary navigation"><a className={route.page === 'dashboard' ? 'active' : ''} aria-current={route.page === 'dashboard' ? 'page' : undefined} href="/" onClick={(event) => handleNavigation(event, { page: 'dashboard' })}>Dashboard</a><a className={route.page === 'repositories' ? 'active' : ''} aria-current={route.page === 'repositories' ? 'page' : undefined} href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Repositories <span>{repositories.length || '—'}</span></a><a className={route.page === 'activity' ? 'active' : ''} aria-current={route.page === 'activity' ? 'page' : undefined} href="/activity" onClick={(event) => handleNavigation(event, { page: 'activity' })}>Activity</a><a className={route.page === 'queue' ? 'active' : ''} aria-current={route.page === 'queue' ? 'page' : undefined} href="/queue" onClick={(event) => handleNavigation(event, { page: 'queue' })}>Scan queue</a><a className={route.page === 'settings' ? 'active' : ''} aria-current={route.page === 'settings' ? 'page' : undefined} href="/settings" onClick={(event) => handleNavigation(event, { page: 'settings' })}>Settings</a></nav>
const renderTopbar = () => <header className="app-header"><a className="brand-home" href="/" onClick={(event) => handleNavigation(event, { page: 'dashboard' })} aria-label="RepoPulse dashboard"><BrandLockup /></a>{renderGlobalNav()}<div className="header-actions"><button className="shortcut-button" type="button" onClick={() => setSwitcherOpen(true)} aria-keyshortcuts="Control+K Meta+K"><span>Quick switch</span><kbd>⌘K</kbd></button><button className="icon-button sync-icon" type="button" onClick={() => void syncCatalog()} disabled={catalogBusy} aria-label="Synchronize Gitea catalog" title="Synchronize Gitea catalog">↻</button></div></header>
const renderCatalogPage = () => <section className="page-content explorer-page" aria-labelledby="repositories-title"><div className="page-heading"><div><span className="eyebrow">Repository explorer</span><h1 id="repositories-title">Repositories <span className="count-badge">{repositories.length}</span></h1><p className="muted">High-density catalog controls when you need breadth; switch to visual identity tiles when you need pattern recognition.</p></div><div className="page-heading-actions"><button className="ghost" type="button" onClick={() => setSwitcherOpen(true)}>Quick switcher <kbd>⌘K</kbd></button><button type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>{catalogBusy ? 'Syncing…' : 'Sync Gitea catalog'}</button></div></div><section className="catalog-toolbar section-card" aria-label="Repository filters"><div className="search-control"><span className="search-glyph" aria-hidden="true">⌕</span><label className="sr-only" htmlFor="catalog-search">Search repositories</label><input id="catalog-search" value={catalogFilters.query} onChange={(event) => updateCatalogFilter('query', event.target.value)} placeholder="Search name, owner or full name" /><kbd>⌘K</kbd></div><div className="filter-grid"><label>State<select aria-label="Filter by state" value={catalogFilters.state} onChange={(event) => updateCatalogFilter('state', event.target.value as CatalogFilters['state'])}><option value="all">All states</option><option value="current">Current</option><option value="stale">Stale</option><option value="scanning">Scanning</option><option value="failed">Failed</option><option value="never_scanned">Never scanned</option><option value="archived">Archived</option></select></label><label>Readiness<select aria-label="Filter by readiness" value={catalogFilters.readiness} onChange={(event) => updateCatalogFilter('readiness', event.target.value as CatalogFilters['readiness'])}><option value="all">All readiness</option><option value="ready">Ready</option><option value="warnings">Warnings</option><option value="blocked">Blocked</option></select></label><label>Owner<select aria-label="Filter by owner" value={catalogFilters.owner} onChange={(event) => updateCatalogFilter('owner', event.target.value)}><option value="all">All owners</option>{ownerOptions.map((owner) => <option key={owner} value={owner}>{owner}</option>)}</select></label><label>Branch<select aria-label="Filter by branch" value={catalogFilters.branch} onChange={(event) => updateCatalogFilter('branch', event.target.value)}><option value="all">All branches</option>{branchOptions.map((branch) => <option key={branch} value={branch}>{branch}</option>)}</select></label><label>Visibility<select aria-label="Filter by visibility" value={catalogFilters.visibility} onChange={(event) => updateCatalogFilter('visibility', event.target.value as CatalogFilters['visibility'])}><option value="all">Any visibility</option><option value="private">Private</option><option value="public">Public</option></select></label><label>Lifecycle<select aria-label="Filter by lifecycle" value={catalogFilters.archived} onChange={(event) => updateCatalogFilter('archived', event.target.value as CatalogFilters['archived'])}><option value="active">Active only</option><option value="all">Active and archived</option><option value="archived">Archived only</option></select></label></div><div className="toolbar-footer"><span className="filter-result">{filteredRepositories.length} matching {repositories.length} repositories</span><div className="view-toggle" aria-label="Repository view"><button className={catalogView === 'table' ? 'active' : ''} type="button" onClick={() => setCatalogView('table')} aria-pressed={catalogView === 'table'}>Table</button><button className={catalogView === 'grid' ? 'active' : ''} type="button" onClick={() => setCatalogView('grid')} aria-pressed={catalogView === 'grid'}>Visual grid</button></div><button className="text-button" type="button" onClick={() => { setCatalogFilters(DEFAULT_FILTERS); setCatalogPage(1) }}>Reset filters</button></div></section>{catalogLoading ? <section className="catalog-loading" aria-label="Loading repository catalog"><span /><span /><span /><span /></section> : catalogWindow.items.length ? <>{catalogView === 'table' ? <section className="catalog-table-card section-card" aria-label="Repository catalog results"><div className="table-scroll"><table className="repository-table"><thead><tr><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('name')}>Repository <span>{catalogSort.key === 'name' ? catalogSort.direction === 'asc' ? '↑' : '↓' : '↕'}</span></button></th><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('state')}>State <span>{catalogSort.key === 'state' ? catalogSort.direction === 'asc' ? '↑' : '↓' : '↕'}</span></button></th><th>Readiness</th><th>Branch</th><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('last_sLine truncated
const renderDetailPage = () => {
if (!selectedRepository) return <section className="page-content"><div className="empty-state section-card"><BrandMark size={38} label="" /><h1>This repository is not in the current catalog.</h1><p className="muted">It may have been archived, removed or the link may be stale.</p><a className="button-link" href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Back to repositories</a></div></section>
const readiness = repositoryReadiness(selectedRepository); const revision = report?.source.actual_revision || selectedRepository.latest_revision
return <section className="page-content detail-page" aria-labelledby="repository-title"><div className="breadcrumb-row"><a href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Repository explorer</a><span aria-hidden="true">/</span><button type="button" onClick={() => setSwitcherOpen(true)}>{selectedRepository.full_name} <span aria-hidden="true">⌄</span></button></div><section className="workspace-hero"><div className="workspace-identity"><div><span className="eyebrow">{selectedRepository.provider} repository · {selectedRepository.private ? 'private' : 'public'}</span><h1 id="repository-title">{selectedRepository.full_name}</h1><p>{selectedRepository.description || 'No repository description provided.'}</p><div className="workspace-facts"><span><code>{selectedRepository.default_branch}</code></span><span><code>{shortRevision(revision)}</code></span><span className={`readiness-label readiness-${readiness}`}><i aria-hidden="true" />{stateLabel(selectedRepository.state)}</span></div></div><div className="workspace-score"><strong>{report?.health_score ?? selectedRepository.last_scan_health ?? '—'}</strong><span>health score</span><small>{report?.release_readiness ?? readinessLabel(readiness)}</small></div></div><RepositoryVisual repository={selectedRepository} report={report} className="workspace-visual" /><div className="detail-actions"><button type="button" onClick={() => void analyzeRepository(selectedRepository)} disabled={busy || Boolean(activeJob)}>{activeJob ? 'Scanning…' : 'Scan now'}</button><details className="action-menu"><summary>Reports</summary><div className="menu-popover"><button type="button" onClick={() => void exportReport('html')} disabled={!report}>Export HTML</button><button type="button" onClick={() => void exportReport('json')} disabled={!report}>Export JSON</button></div></details><details className="action-menu"><summary>More</summary><div className="menu-popover"><button type="button" onClick={() => void analyzeRepository(selectedRepository, true)} disabled={busy || Boolean(activeJob)}>Force rescan</button><button type="button" onClick={() => navigate({ page: 'repositories', repositoryId: selectedRepository.id, view: 'scans' })}>View scan runs</button><button type="button" onClick={() => setAdvancedOpen((current) => !current)}>Advanced info</button></div></details><button className={`star-button header-star ${pinnedIds.includes(selectedRepository.id) ? 'pinned' : ''}`} type="button" onClick={() => togglePinned(selectedRepository.id)} aria-label={pinnedIds.includes(selectedRepository.id) ? 'Remove from pinned repositories' : 'Pin repository'} aria-pressed={pinnedIds.includes(selectedRepository.id)}><span aria-hidden="true">{pinnedIds.includes(selectedRepository.id) ? '★' : '☆'}</span></button></div></section>{error && <div className="error-banner" role="alert">{error}</div>}{notice && <div className="notice-banner" role="status">{notice}</div>}{activeJob && <div className="job-progress" role="status"><div><strong>{activeJob.message || 'Analysis in progress'}</strong><span>{activeJob.phase.replaceAll('_', ' ')} · {activeJob.progress}%</span></div><div className="progress-track"><i style={{ width: `${activeJob.progress}%` }} /></div></div>}<nav className="detail-tabs" aria-label="Repository analysis views">{DETAIL_VIEWS.map((view) => <a key={view} className={currentView === view ? 'active' : ''} aria-current={currentView === view ? 'page' : undefined} href={routePath({ page: 'repositories', repositoryId: selectedRepository.id, view })} onClick={(event) => handleNavigation(event, { page: 'repositories', repositoryId: selectedRepository.id, view })}>{detailViewLabel(view, report)}</a>)}</nav>{advancedOpen && <section className="section-card advanced-info"><div><span className="eyebrow">Advanced repository info</span><h2>Identity and policy</h2></div><div className="info-grid"><span><strong>Provider ID</strong><code>{selectedRepository.provider_repository_id}</code></span><span><strong>Visibility</strong>{selectedRepository.private ? 'Private' : 'Public'}</span><span><strong>Auto scan</strong>{selectedRepository.policy.auto_scan ? 'Enabled' : 'Disabled'}</span><span><strong>Minimum health</strong>{selectedRepository.policy.min_health_score}</span><span><strong>Latest revision</strong><code>{shortRevision(selectedRepository.latest_revision)}</code></span><span><strong>Catalog sync</strong>{formatDate(selectedRepository.last_synced_at)}</span></div></section>}{selectedDetailsLoading ? <section className="workspace-loading" role="status"><span /><span /><span /></section> : report ? <section className="detail-content">{currentView === 'overview' && <OverviewView report={report} comparison={comparison} />}{currentView === 'architecture' && <ArchitectureView report={report} />}{currentView === 'hotspots' && <HotspotsView report={report} />}{currentView === 'dependencies' && <DependenciesView report={report} />}{currentView === 'findings' &&Line truncated
}
const renderActivityPage = () => { const activity = [...repositories].filter((repository) => repository.last_scan_at || repository.state === 'scanning' || repository.state === 'queued').sort((left, right) => (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)); return <section className="page-content activity-page" aria-labelledby="activity-title"><div className="page-heading"><div><span className="eyebrow">Operational timeline</span><h1 id="activity-title">Activity</h1><p className="muted">A visual event feed derived from actual catalog state and persisted scan activity.</p></div></div><div className="summary-strip"><div><span>Catalog</span><strong>{repositories.length}</strong></div><div><span>Current</span><strong>{repositories.filter((repository) => repository.state === 'current').length}</strong></div><div><span>Needs attention</span><strong>{repositories.filter((repository) => repositoryReadiness(repository) !== 'ready').length}</strong></div><div><span>Latest scan</span><strong>{activity[0] ? formatRelativeDate(activity[0].last_scan_at) : '—'}</strong></div></div><section className="activity-timeline section-card"><div className="section-heading"><div><span className="eyebrow">Latest changes</span><h2>Repository activity</h2></div><span className="section-note">Each event opens its repository workspace.</span></div>{activity.length ? activity.slice(0, 30).map((repository) => <button type="button" className="timeline-event" key={repository.id} onClick={() => selectRepository(repository)}><span className={`timeline-rail ${repositoryReadiness(repository)}`} /><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.state === 'scanning' || repository.state === 'queued' ? `${stateLabel(repository.state)} · ${repository.default_branch}` : `Scan completed ${formatRelativeDate(repository.last_scan_at)} · ${repository.default_branch}`}</small></span><b>{repository.last_scan_health ?? '—'}</b></button>) : <div className="empty-state compact-empty"><BrandMark size={34} label="" /><h2>No activity yet</h2><p className="muted">Scan a repository to start building its operational timeline.</p></div>}</section></section> }
const renderQueuePage = () => { const queue = repositories.filter((repository) => repository.active_job_id || repository.state === 'queued' || repository.state === 'scanning'); return <section className="page-content queue-page" aria-labelledby="queue-title"><div className="page-heading"><div><span className="eyebrow">Durable background work</span><h1 id="queue-title">Scan queue</h1><p className="muted">Exact revisions are retained while work moves through source acquisition, analysis and scoring.</p></div></div><div className="summary-strip"><div><span>Waiting</span><strong>{queue.filter((repository) => repository.state === 'queued').length}</strong></div><div><span>Active</span><strong>{queue.filter((repository) => repository.state === 'scanning' || repository.active_job_id).length}</strong></div><div><span>Completed</span><strong>{meta?.completed_jobs ?? '—'}</strong></div><div><span>Failed</span><strong>{meta?.failed_jobs ?? '—'}</strong></div></div><section className="queue-workspace section-card"><div className="section-heading"><div><span className="eyebrow">Live queue</span><h2>{queue.length ? 'Scans in progress' : 'Queue is clear'}</h2></div><span className="section-note">No misleading progress percentage is shown before the worker reports one.</span></div>{queue.length ? <div className="queue-list">{queue.map((repository) => <button type="button" className="queue-row" key={repository.id} onClick={() => selectRepository(repository)}><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.default_branch} · {repository.active_job_id ? 'worker active' : 'waiting for worker'}</small></span><span className={`status-label status-${statusClass(repository.state)}`}><i aria-hidden="true" />{stateLabel(repository.state)}</span></button>)}</div> : <div className="queue-empty"><BrandMark size={48} label="" /><div><span className="eyebrow">All clear</span><h2>No scans are waiting</h2><p className="muted">The worker is healthy and the portfolio is up to date. Start a scan from a repository workspace whenever a fresh revision needs analysis.</p><a className="button-link" href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Browse repositories</a></div></div>}</section></section> }
const renderSettingsPage = () => <section className="page-content settings-page" aria-labelledby="settings-title"><div className="page-heading"><div><span className="eyebrow">Workspace configuration</span><h1 id="settings-title">Settings</h1><p className="muted">Connection, scanning and local preferences are separated into concise operational sections.</p></div></div><div className="settings-sections"><section className="settings-section"><div><span className="eyebrow">Connections</span><h2>Gitea catalog</h2><p>Provider-backed repository discovery and exact revision acquisition.</p></div><dl><div><dt>Status</dt><dd><span className={`status-label status-${meta?.gitea.configured ? 'current' : 'failed'}`}><i aria-hidden="true" />{meta?.gitea.configured ? 'Configured' : 'Not configured'}</span></dd></div><div><dt>Repositories</dt><dd>{meta?.repository_count ?? repositories.length}</dd></div><div><dt>Last sync</dt><dd>{meta?.gitea.last_sync && typeof meta.gitea.last_sync === 'object' && 'synced_at' in meta.gitea.last_sync ? formatDate(String(meta.gitea.last_sync.synced_at)) : 'Not available'}</dd></div></dl><button className="ghost" type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>{catalogBusy ? 'Syncing…' : 'Sync catalog now'}</button></section><section className="settings-section"><div><span className="eyebrow">Scanning & analysis</span><h2>Safe source controls</h2><p>The normal repository flow stays Gitea-native. The advanced path remains available only for mounted local sources.</p></div><dl><div><dt>Scan root</dt><dd>{meta?.scan_root_restricted ? 'Restricted' : 'Configured by server'}</dd></div><div><dt>Concurrency</dt><dd>{meta?.max_concurrent_scans ?? '—'} worker</dd></div><div><dt>Automation</dt><dd>Scheduled discovery enabled</dd></div></dl><form className="local-form" onSubmit={scanLocal}><label><span>Advanced repository path</span><input aria-label="Repository path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/scan/my-project" required /></label><button disabled={busy}>{busy ? 'Analyzing…' : 'Analyze local source'}</button></form>{standaloneReport && <div className="local-result" role="status"><strong>{standaloneReport.repository_name}</strong><span>{standaloneReport.health_score}/100 · {standaloneReport.release_readiness} · {formatDate(standaloneReport.scanned_at)}</span></div>}</section><section className="settings-section appearance"><div><span className="eyebrow">Appearance</span><h2>Personal workspace preferences</h2><p>Dark signal topology is the primary theme. View modes and navigation preferences are stored locally in this browser.</p></div><dl><div><dt>Explorer view</dt><dd><div className="view-toggle"><button className={catalogView === 'table' ? 'active' : ''} type="button" onClick={() => setCatalogView('table')}>Table</button><button className={catalogView === 'grid' ? 'active' : ''} type="button" onClick={() => setCatalogView('grid')}>Visual grid</button></div></dd></div><div><dt>Pinned repositories</dt><dd>{pinnedIds.length}</dd></div><div><dt>Recent repositories</dt><dd>{recentIds.length}</dd></div></dl></section></div></section>
return <main className="app-shell">{renderTopbar()}{error && !route.repositoryId && <div className="error-banner global-banner" role="alert">{error}</div>}{notice && !route.repositoryId && <div className="notice-banner global-banner" role="status">{notice}</div>}{route.page === 'dashboard' && <DashboardView repositories={repositories} pinnedIds={pinnedIds} onOpenRepository={selectRepository} onExploreReadiness={exploreReadiness} onOpenExplorer={() => navigate({ page: 'repositories' })} />}{route.page === 'activity' && renderActivityPage()}{route.page === 'queue' && renderQueuePage()}{route.page === 'settings' && renderSettingsPage()}{route.page === 'repositories' && (route.repositoryId ? renderDetailPage() : renderCatalogPage())}{switcherOpen && <div className="modal-backdrop" role="presentation" onMouseDown={() => setSwitcherOpen(false)}><section className="switcher-modal" role="dialog" aria-modal="true" aria-labelledby="switcher-title" onMouseDown={(event) => event.stopPropagation()}><div className="switcher-heading"><div><span className="eyebrow">Repository navigation</span><h2 id="switcher-title">Quick switcher</h2></div><button className="icon-button" type="button" onClick={() => setSwitcherOpen(false)} aria-label="Close quick switcher">×</button></div><div className="command-row"><button type="button" onClick={() => { navigate({ page: 'dashboard' }); setSwitcherOpen(false) }}>Open dashboard</button><button type="button" onClick={() => { navigate({ page: 'queue' }); setSwitcherOpen(false) }}>Open scan queue</button><button type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>Sync Gitea</button></div><label className="switcher-search"><span className="sr-only">Search repositories</span><input ref={switcherInputRef} value={switcherQuery} onChange={(event) => { setSwitcherQuery(event.target.value); setSwitcherIndex(0) }} placeholder="Search repositories…" /><kbd>Esc</kbd></label>{switcherRepositories.length ? <div className="switcher-results" role="listbox" aria-label="Repositories">{switcherRepositories.map((repository, index) => <button className={index === switcherIndex ? 'switcher-item active' : 'switcher-item'} type="button" role="option" aria-selected={index === switcherIndex} key={repository.id} onMouseEnter={() => setSwitcherIndex(index)} onClick={() => { selectRepository(repository); setSwitcherOpen(false) }}><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.owner} · {repository.default_branch} · {repository.last_scan_health ?? 'not scanned'}</small></span><span className={`readiness-label readiness-${repositoryReadiness(repository)}`}><i aria-hidden="true" />{readinessLabel(repositoryReadiness(repository))}</span></button>)}</div> : <div className="empty-state compact-empty"><h2>No repository matches</h2><p className="muted">Try the owner, repository name or branch.</p></div>}<div className="switcher-footer"><span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span><span><kbd>Enter</kbd> Open</span><span><kbd>Esc</kbd> Close</span></div></section></div>}</main>
}
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { ScanReport } from './api'
import { FindingsView } from './components/FindingsView'
const report: ScanReport = {
id: 'scan-1',
repository_path: '/scan/repopulse',
repository_name: 'RepoPulse',
source: {
origin: 'gitea',
provider: 'gitea',
repository_id: '64',
repository_full_name: 'Jens/RepoPulse',
branch: 'main',
requested_revision: 'revision-1',
actual_revision: 'revision-1',
trigger: 'scheduled',
},
policy: {
auto_scan: true,
min_health_score: 70,
max_blockers: 0,
require_tests: true,
require_ci: true,
suppressions: [],
},
scanned_at: '2026-09-12T20:00:00Z',
duration_ms: 1200,
metrics: {
files: 2,
source_files: 2,
lines_of_code: 20,
test_files: 1,
todo: 0,
fixme: 0,
hack: 0,
xxx: 0,
dependency_manifests: 1,
declared_dependencies: 1,
large_files: 0,
large_source_files: 0,
},
languages: { TypeScript: 20 },
category_scores: { 'Security basics': 20, Maintainability: 80 },
health_score: 70,
release_readiness: 'BLOCKED',
findings: [
{
code: 'security.secret-assignment',
category: 'Security basics',
severity: 'blocker',
message: 'Potential credential assignment detected; value intentionally redacted',
path: 'backend/app/main.py',
line: 245,
deduction: 20,
fingerprint: 'fingerprint-1',
suppressed: false,
},
{
code: 'maintainability.long-function',
category: 'Maintainability',
severity: 'warning',
message: 'Function is longer than the configured threshold',
path: 'backend/app/signals.py',
line: 111,
deduction: 4,
fingerprint: 'fingerprint-2',
suppressed: false,
},
],
tests: { frameworks: ['pytest'], coverage_artifacts: [], ci_workflows: [], test_source_ratio: null },
git: { available: true, commit_count: 1, contributor_count: 1, latest_commit_at: null, tracked_files: 2, renamed_files: [] },
dependencies: { manifests: ['package.json'], lockfiles: ['package-lock.json'], declared_dependencies: 1, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [] },
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
hotspots: [],
release_gate: null,
}
afterEach(() => cleanup())
describe('FindingsView', () => {
it('keeps findings scanable while exposing expandable rule details', () => {
const { container } = render(<FindingsView report={report} />)
const firstFinding = container.querySelector('details.finding') as HTMLDetailsElement
expect(firstFinding.open).toBe(false)
expect(screen.getByText('Potential credential assignment detected; value intentionally redacted')).toBeTruthy()
fireEvent.click(screen.getByText('Potential credential assignment detected; value intentionally redacted'))
expect(firstFinding.open).toBe(true)
expect(screen.getByText('−20 points')).toBeTruthy()
expect(screen.getByText('backend/app/main.py:245')).toBeTruthy()
expect(screen.getByText('security.secret-assignment')).toBeTruthy()
})
it('filters by severity and reports an actionable empty state', () => {
render(<FindingsView report={report} />)
const severity = screen.getByRole('combobox', { name: 'Filter findings by severity' })
const query = screen.getByRole('textbox', { name: 'Filter findings' })
fireEvent.change(severity, { target: { value: 'warning' } })
expect(screen.getByText(/Showing 1 of 2/)).toBeTruthy()
fireEvent.change(query, { target: { value: 'does-not-exist' } })
expect(screen.getByText('No findings match the current filters.')).toBeTruthy()
})
})
+50
View File
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { OverviewView } from './components/OverviewView'
import { testComparison, testReport } from './test-fixtures'
afterEach(() => cleanup())
describe('OverviewView', () => {
it('prioritizes readiness and surfaces context for a low-signal repository', () => {
const report = testReport({
metrics: {
files: 0,
source_files: 0,
lines_of_code: 0,
test_files: 0,
todo: 0,
fixme: 0,
hack: 0,
xxx: 0,
dependency_manifests: 0,
declared_dependencies: 0,
large_files: 0,
large_source_files: 0,
},
dependencies: {
manifests: [],
lockfiles: [],
declared_dependencies: 0,
unbounded_dependencies: 0,
unpinned_dependencies: 0,
duplicate_dependencies: [],
},
languages: {},
findings: [],
release_readiness: 'READY',
release_gate: null,
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
hotspots: [],
})
const { container } = render(<OverviewView report={report} comparison={testComparison} />)
expect(screen.getByRole('status').textContent).toContain('little or no analyzable source code')
expect(screen.getAllByText('READY').length).toBeGreaterThan(0)
expect(container.querySelector('.metric-grid.low-signal')).toBeTruthy()
})
})
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('RepoPulse API client', () => {
it('submits a local repository scan request', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 'scan-1' }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await api.scan('/scan/project')
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/scans',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ path: '/scan/project' }),
}),
)
})
it('encodes repository paths for trend requests', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await api.trend('/scan/Project With Space')
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/trend?path=%2Fscan%2FProject%20With%20Space&limit=50')
})
it('uses the comparison endpoint for persisted scan deltas', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
current_scan_id: 'scan-2',
previous_scan_id: 'scan-1',
health_score_delta: 4,
category_score_deltas: {},
metric_deltas: {},
added_findings: [],
resolved_findings: [],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
)
vi.stubGlobal('fetch', fetchMock)
const result = await api.comparison('scan-2')
expect(result.health_score_delta).toBe(4)
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/scans/scan-2/comparison')
})
it('uses repository identity and queues a revision-aware scan without a path', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 'job-1', status: 'queued' }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await api.enqueueRepositoryScan('gitea:64')
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/repositories/gitea%3A64/scans')
expect(fetchMock.mock.calls[0][1]).toEqual(expect.objectContaining({
method: 'POST',
body: JSON.stringify({ force: false }),
}))
})
})
+386
View File
@@ -0,0 +1,386 @@
export type Severity = 'info' | 'warning' | 'blocker'
export type GateState = 'pass' | 'warn' | 'block'
export type SourceOrigin = 'local' | 'gitea'
export type ScanTrigger = 'manual' | 'scheduled' | 'webhook' | 'local'
export type ScanJobStatus = 'queued' | 'acquiring_source' | 'scanning' | 'analyzing' | 'persisting' | 'completed' | 'failed' | 'cancelled' | 'superseded'
export type RepositoryState = 'never_scanned' | 'current' | 'stale' | 'queued' | 'scanning' | 'failed' | 'unavailable' | 'archived'
export type Finding = {
code: string
category: string
severity: Severity
message: string
path: string | null
line: number | null
deduction: number
fingerprint: string | null
suppressed: boolean
guidance?: FindingGuidance | null
}
export type FindingGuidance = {
remediation_class: string
confidence: string
why: string
recommended_action: string
priority: number
priority_rationale: string
}
export type RepositoryMetrics = {
files: number
source_files: number
lines_of_code: number
test_files: number
todo: number
fixme: number
hack: number
xxx: number
dependency_manifests: number
declared_dependencies: number
large_files: number
large_source_files: number
}
export type TestSignals = {
frameworks: string[]
coverage_artifacts: string[]
ci_workflows: string[]
ci_validation_workflows?: string[]
ci_build_workflows?: string[]
ci_deploy_only_workflows?: string[]
coverage_evidence?: string[]
test_source_ratio: number | null
}
export type GitSignals = {
available: boolean
commit_count: number | null
contributor_count: number | null
latest_commit_at: string | null
tracked_files: number | null
renamed_files: string[]
}
export type DependencySignals = {
manifests: string[]
lockfiles: string[]
declared_dependencies: number
unbounded_dependencies: number
unpinned_dependencies: number
duplicate_dependencies: string[]
included_requirements?: string[]
}
export type ArchitectureNode = {
id: string
label: string
path: string
language: string
inbound: number
outbound: number
visible_inbound: number
visible_outbound: number
hotspot_score: number
}
export type ArchitectureEdge = {
source: string
target: string
kind: string
}
export type ArchitectureGraph = {
nodes: ArchitectureNode[]
edges: ArchitectureEdge[]
clusters: Record<string, number>
truncated: boolean
total_nodes: number
total_edges: number
}
export type Hotspot = {
path: string
language: string
lines_of_code: number
debt_markers: number
churn: number
inbound: number
outbound: number
score: number
reasons: string[]
}
export type GateCheck = {
key: string
label: string
state: GateState
detail: string
}
export type ReleaseGate = {
status: string
checks: GateCheck[]
}
export type RepositoryPolicy = {
auto_scan: boolean
min_health_score: number
max_blockers: number
require_tests: boolean
require_ci: boolean
suppressions: string[]
}
export type RemediationSignal = {
code: string
category: string
severity: Severity
message: string
count: number
guidance?: FindingGuidance | null
}
export type ScanLimits = {
max_files: number
max_text_bytes_per_file: number
max_total_text_bytes?: number
max_findings: number
max_secret_findings: number
}
export type ScanCoverage = {
limits: ScanLimits
discovered_files: number
scanned_files: number
skipped_files_by_budget: number
skipped_files_by_size: number
skipped_files_by_text_budget?: number
text_bytes_scanned: number
truncated: boolean
notes: string[]
}
export type ScanSource = {
origin: SourceOrigin
provider: string | null
repository_id: string | null
repository_full_name: string | null
branch: string | null
requested_revision: string | null
actual_revision: string | null
trigger: ScanTrigger
}
export type ScanReport = {
id: string
repository_path: string
repository_name: string
source: ScanSource
policy: RepositoryPolicy
scanned_at: string
duration_ms: number
metrics: RepositoryMetrics
languages: Record<string, number>
category_scores: Record<string, number>
health_score: number
release_readiness: string
findings: Finding[]
tests: TestSignals
git: GitSignals
dependencies: DependencySignals
architecture: ArchitectureGraph
hotspots: Hotspot[]
release_gate: ReleaseGate | null
coverage?: ScanCoverage | null
compacted?: boolean
}
export type RepositorySummary = {
id: string
provider: string
provider_repository_id: string
owner: string
name: string
full_name: string
description: string | null
html_url: string | null
clone_url: string | null
default_branch: string
private: boolean
archived: boolean
available: boolean
latest_revision: string | null
latest_revision_at: string | null
last_synced_at: string | null
sync_error: string | null
state: RepositoryState
stale: boolean
active_job_id: string | null
last_scan_id: string | null
last_scan_at: string | null
last_scan_health: number | null
last_scan_readiness: string | null
last_scan_revision: string | null
last_scan_blockers: number
last_scan_warnings: number
last_scan_primary_signal: RemediationSignal | null
last_scan_health_delta: number | null
last_scan_added_findings: number
last_scan_resolved_findings: number
last_scan_priority?: number
last_scan_priority_rationale?: string | null
policy: RepositoryPolicy
}
export type RepositoryDetail = RepositorySummary & { latest_scan: ScanReport | null }
export type AppMeta = {
name: string
version: string
stage: string
scan_root_restricted: boolean
scan_root: string | null
max_concurrent_scans: number
repository_count: number
repository_states: Record<string, number>
current_repositories: number
stale_repositories: number
queue_depth: number
active_jobs: number
completed_jobs: number
failed_jobs: number
worker_heartbeat: string | null
gitea: {
configured: boolean
url: string | null
last_sync: unknown
}
storage?: {
database_bytes: number
journal_bytes: number
mirror_count: number
mirror_cache_bytes: number
worktree_count: number
scan_count: number
compacted_scan_count: number
oldest_scan_at: string | null
newest_scan_at: string | null
history_per_repository: number
full_reports_per_repository: number
report_retention_days: number
last_schema_migration_backup?: string | null
}
database_recovery?: Record<string, unknown>
mutation_authentication?: 'operator_token' | 'perimeter_required'
}
export type ScanJob = {
id: string
repository_id: string
status: ScanJobStatus
trigger: ScanTrigger
requested_revision: string | null
actual_revision: string | null
phase: string
progress: number
message: string | null
created_at: string
started_at: string | null
updated_at: string
finished_at: string | null
duration_ms: number | null
scan_id: string | null
error_code: string | null
error_detail: string | null
attempt: number
deduplicated: boolean
}
export type ScanSummary = {
id: string
repository_id: string
scanned_at: string
branch: string | null
requested_revision: string | null
actual_revision: string | null
trigger: ScanTrigger
health_score: number
release_readiness: string
findings: number
duration_ms: number
}
export type RepositorySyncResult = {
provider: string
discovered: number
created: number
updated: number
unavailable: number
errors: string[]
partial?: boolean
branch_failures?: number
listing_complete?: boolean
synced_at: string
}
export type ScanComparison = {
current_scan_id: string
previous_scan_id: string | null
health_score_delta: number | null
category_score_deltas: Record<string, number>
metric_deltas: Record<string, number>
added_findings: Finding[]
resolved_findings: Finding[]
}
export type TrendPoint = {
scan_id: string
scanned_at: string
health_score: number
findings: number
blockers: number
warnings: number
debt_markers: number
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.detail || `Request failed (${response.status})`)
}
return response.json() as Promise<T>
}
const repositoryPath = (id: string) => encodeURIComponent(id)
export const api = {
scan: (path: string) => request<ScanReport>('/api/v1/scans', {
method: 'POST',
body: JSON.stringify({ path }),
}),
recent: () => request<ScanReport[]>('/api/v1/scans?limit=20'),
meta: () => request<AppMeta>('/api/v1/meta'),
repositories: () => request<RepositorySummary[]>('/api/v1/repositories?limit=1000'),
repository: (id: string) => request<RepositoryDetail>(`/api/v1/repositories/${repositoryPath(id)}`),
repositoryScans: (id: string) => request<ScanSummary[]>(`/api/v1/repositories/${repositoryPath(id)}/scans?limit=200`),
repositoryTrend: (id: string) => request<TrendPoint[]>(`/api/v1/repositories/${repositoryPath(id)}/trend?limit=100`),
syncRepositories: () => request<RepositorySyncResult>('/api/v1/repositories/sync', { method: 'POST' }),
enqueueRepositoryScan: (id: string, force = false) => request<ScanJob>(`/api/v1/repositories/${repositoryPath(id)}/scans`, {
method: 'POST',
body: JSON.stringify({ force }),
}),
scanJob: (id: string) => request<ScanJob>(`/api/v1/scan-jobs/${encodeURIComponent(id)}`),
repositoryScan: (repositoryId: string, scanId: string) => request<ScanReport>(`/api/v1/repositories/${repositoryPath(repositoryId)}/scans/${encodeURIComponent(scanId)}`),
comparison: (scanId: string) => request<ScanComparison>(`/api/v1/scans/${encodeURIComponent(scanId)}/comparison`),
trend: (path: string) => request<TrendPoint[]>(`/api/v1/trend?path=${encodeURIComponent(path)}&limit=50`),
exportReport: async (scanId: string, format: 'html' | 'json') => {
const response = await fetch(`/api/v1/scans/${encodeURIComponent(scanId)}/export?format=${format}`)
if (!response.ok) throw new Error(`Export failed (${response.status})`)
return response.text()
},
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { filterRepositories, orderSwitcherRepositories, paginateRepositories, sortRepositories } from './catalog'
import type { RepositorySummary } from './api'
function repository(index: number): RepositorySummary {
return {
id: `gitea:${index}`,
provider: 'gitea',
provider_repository_id: String(index),
owner: index % 2 ? 'Jens' : 'Team',
name: `repo-${String(index).padStart(3, '0')}`,
full_name: `${index % 2 ? 'Jens' : 'Team'}/repo-${String(index).padStart(3, '0')}`,
description: null,
html_url: null,
clone_url: null,
default_branch: index % 3 ? 'main' : 'develop',
private: index % 4 === 0,
archived: index % 17 === 0,
available: true,
latest_revision: `revision-${index}`,
latest_revision_at: '2026-09-12T10:00:00Z',
last_synced_at: '2026-09-12T10:00:00Z',
sync_error: null,
state: index % 11 === 0 ? 'never_scanned' : 'current',
stale: false,
active_job_id: null,
last_scan_id: index % 11 === 0 ? null : `scan-${index}`,
last_scan_at: index % 11 === 0 ? null : '2026-09-12T10:00:00Z',
last_scan_health: index % 11 === 0 ? null : 80 + (index % 20),
last_scan_readiness: index % 11 === 0 ? null : 'READY',
last_scan_revision: `revision-${index}`,
last_scan_blockers: 0,
last_scan_warnings: 0,
last_scan_primary_signal: null,
last_scan_health_delta: null,
last_scan_added_findings: 0,
last_scan_resolved_findings: 0,
policy: { auto_scan: true, min_health_score: 70, max_blockers: 0, require_tests: false, require_ci: false, suppressions: [] },
}
}
describe('repository catalog helpers', () => {
it('filters by owner, branch, visibility and readiness', () => {
const repositories = Array.from({ length: 20 }, (_, index) => repository(index + 1))
const result = filterRepositories(repositories, {
query: 'repo-0',
state: 'all',
readiness: 'ready',
owner: 'Jens',
branch: 'main',
visibility: 'public',
archived: 'active',
})
expect(result.every((item) => item.owner === 'Jens' && item.default_branch === 'main' && !item.private && !item.archived)).toBe(true)
expect(result.length).toBeGreaterThan(0)
})
it('sorts deterministically and paginates large catalogs without rendering all rows', () => {
const repositories = Array.from({ length: 500 }, (_, index) => repository(index + 1))
const sorted = sortRepositories(repositories, { key: 'name', direction: 'asc' })
const page = paginateRepositories(sorted, 7, 25)
expect(page.total).toBe(500)
expect(page.pageCount).toBe(20)
expect(page.page).toBe(7)
expect(page.items).toHaveLength(25)
expect(page.items[0].full_name).toBe('Jens/repo-301')
})
it('puts pinned and recent repositories first in the quick switcher', () => {
const repositories = [repository(1), repository(2), repository(3)]
const result = orderSwitcherRepositories(repositories, ['gitea:3'], ['gitea:2'], 'repo')
expect(result.map((item) => item.id)).toEqual(['gitea:3', 'gitea:2', 'gitea:1'])
})
})
+118
View File
@@ -0,0 +1,118 @@
import type { RepositoryState, RepositorySummary } from './api'
export type Readiness = 'ready' | 'warnings' | 'blocked'
export type CatalogFilters = {
query: string
state: 'all' | RepositoryState
readiness: 'all' | Readiness
owner: string
branch: string
visibility: 'all' | 'private' | 'public'
archived: 'all' | 'active' | 'archived'
}
export type CatalogSortKey = 'name' | 'health' | 'last_scan' | 'state'
export type CatalogSort = {
key: CatalogSortKey
direction: 'asc' | 'desc'
}
export type CatalogPage = {
items: RepositorySummary[]
total: number
page: number
pageSize: number
pageCount: number
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
export function repositoryReadiness(repository: RepositorySummary): Readiness {
if (!repository.available || repository.state === 'failed' || repository.state === 'unavailable') return 'blocked'
if (!repository.last_scan_id || repository.state === 'never_scanned' || repository.last_scan_health == null) return 'warnings'
const persistedReadiness = repository.last_scan_readiness?.toUpperCase() ?? ''
if (persistedReadiness.includes('BLOCK')) return 'blocked'
if (persistedReadiness.includes('WARNING')) return 'warnings'
if (persistedReadiness === 'READY') return 'ready'
if (repository.last_scan_health < 60) return 'blocked'
if (repository.stale || repository.state === 'stale' || repository.state === 'queued' || repository.state === 'scanning' || repository.last_scan_health < 80) return 'warnings'
return 'ready'
}
export function readinessLabel(readiness: Readiness) {
if (readiness === 'ready') return 'Ready'
if (readiness === 'warnings') return 'Warnings'
return 'Blocked'
}
export function stateLabel(state: RepositoryState) {
if (state === 'scanning') return 'Scanning'
if (state === 'queued') return 'Queued'
if (state === 'never_scanned') return 'Never scanned'
if (state === 'current') return 'Current'
if (state === 'stale') return 'Stale'
if (state === 'archived') return 'Archived'
if (state === 'unavailable') return 'Unavailable'
return 'Failed'
}
export function filterRepositories(repositories: RepositorySummary[], filters: CatalogFilters) {
const query = filters.query.trim().toLowerCase()
return repositories.filter((repository) => {
if (query && !`${repository.full_name} ${repository.owner} ${repository.name} ${repository.provider} ${repository.default_branch}`.toLowerCase().includes(query)) return false
if (filters.state !== 'all' && repository.state !== filters.state) return false
if (filters.readiness !== 'all' && repositoryReadiness(repository) !== filters.readiness) return false
if (filters.owner !== 'all' && repository.owner !== filters.owner) return false
if (filters.branch !== 'all' && repository.default_branch !== filters.branch) return false
if (filters.visibility === 'private' && !repository.private) return false
if (filters.visibility === 'public' && repository.private) return false
if (filters.archived === 'active' && repository.archived) return false
if (filters.archived === 'archived' && !repository.archived) return false
return true
})
}
export function sortRepositories(repositories: RepositorySummary[], sort: CatalogSort) {
const sorted = [...repositories].sort((left, right) => {
let comparison = 0
if (sort.key === 'name') comparison = collator.compare(left.full_name, right.full_name)
if (sort.key === 'health') comparison = (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1)
if (sort.key === 'last_scan') comparison = (Date.parse(left.last_scan_at ?? '') || 0) - (Date.parse(right.last_scan_at ?? '') || 0)
if (sort.key === 'state') comparison = collator.compare(stateLabel(left.state), stateLabel(right.state))
if (comparison === 0) comparison = collator.compare(left.full_name, right.full_name)
return sort.direction === 'asc' ? comparison : -comparison
})
return sorted
}
export function paginateRepositories(repositories: RepositorySummary[], page: number, pageSize: number): CatalogPage {
const safePageSize = Math.max(1, pageSize)
const pageCount = Math.max(1, Math.ceil(repositories.length / safePageSize))
const safePage = Math.min(Math.max(1, page), pageCount)
const start = (safePage - 1) * safePageSize
return {
items: repositories.slice(start, start + safePageSize),
total: repositories.length,
page: safePage,
pageSize: safePageSize,
pageCount,
}
}
export function orderSwitcherRepositories(repositories: RepositorySummary[], pinnedIds: string[], recentIds: string[], query = '') {
const needle = query.trim().toLowerCase()
return repositories
.filter((item) => !needle || `${item.full_name} ${item.owner} ${item.name} ${item.default_branch}`.toLowerCase().includes(needle))
.sort((left, right) => {
const leftPinned = pinnedIds.includes(left.id) ? 0 : 1
const rightPinned = pinnedIds.includes(right.id) ? 0 : 1
if (leftPinned !== rightPinned) return leftPinned - rightPinned
const leftRecent = recentIds.indexOf(left.id)
const rightRecent = recentIds.indexOf(right.id)
if (leftRecent !== rightRecent) return (leftRecent < 0 ? 99 : leftRecent) - (rightRecent < 0 ? 99 : rightRecent)
return left.full_name.localeCompare(right.full_name)
})
.slice(0, 50)
}
@@ -0,0 +1,42 @@
import { useMemo, useState } from 'react'
import { Background, Controls, MiniMap, ReactFlow, type Edge, type Node } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import type { ArchitectureNode, ScanReport } from '../api'
export function ArchitectureView({ report }: { report: ScanReport }) {
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const visibleSource = useMemo(() => {
const needle = query.trim().toLocaleLowerCase()
return report.architecture.nodes.filter((node) => !needle || `${node.label} ${node.path} ${node.language}`.toLocaleLowerCase().includes(needle))
}, [query, report.architecture.nodes])
const grouped = useMemo(() => {
const groups = new Map<string, ArchitectureNode[]>()
for (const node of visibleSource) {
const cluster = node.path.includes('/') ? node.path.split('/')[0] : 'root'
groups.set(cluster, [...(groups.get(cluster) ?? []), node])
}
return groups
}, [visibleSource])
const nodes = useMemo(() => {
const result: Node[] = []
Array.from(grouped.entries()).forEach(([cluster, items], clusterIndex) => {
const columns = 8
const clusterWidth = columns * 230 + 260
const orderedItems = [...items].sort((left, right) => right.hotspot_score - left.hotspot_score || left.path.localeCompare(right.path))
orderedItems.forEach((item, index) => result.push({
id: item.id,
position: { x: clusterIndex * clusterWidth + (index % columns) * 230, y: Math.floor(index / columns) * 150 },
data: { label: `${item.label} · ${item.hotspot_score}` },
className: item.id === selectedId ? 'arch-selected' : item.hotspot_score >= 70 ? 'arch-hot' : item.hotspot_score >= 40 ? 'arch-warm' : 'arch-calm',
}))
})
return result
}, [grouped, selectedId])
const visible = new Set(nodes.map((node) => node.id))
const edges: Edge[] = report.architecture.edges.filter((edge) => visible.has(edge.source) && visible.has(edge.target)).map((edge, index) => ({ id: `${edge.source}-${edge.target}-${index}`, source: edge.source, target: edge.target, animated: edge.source === selectedId || edge.target === selectedId }))
const selected = visibleSource.find((node) => node.id === selectedId) ?? null
return <div className="view-stack"><section className="section-card architecture-summary"><div><span className="eyebrow">Architecture intelligence</span><h2>Local import and dependency topology</h2><p className="muted">Search, pan and zoom the bounded graph. Selecting a module reveals its local coupling and hotspot signals.</p></div><div className="architecture-stats"><div><strong>{nodes.length}<small>/{report.architecture.total_nodes || report.architecture.nodes.length}</small></strong><span>visible / total nodes</span></div><div><strong>{edges.length}<small>/{report.architecture.total_edges || report.architecture.edges.length}</small></strong><span>visible / total edges</span></div><div><strong>{grouped.size}</strong><span>clusters</span></div></div></section><section className="architecture-workspace section-card"><div className="architecture-toolbar"><label><span className="sr-only">Search architecture</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search module, path or language" /></label><span>{visibleSource.length} modules visible</span></div><div className="architecture-main"><div className="architecture-canvas">{nodes.length ? <ReactFlow nodes={nodes} edges={edges} fitView minZoom={0.1} onNodeClick={(_, node) => setSelectedId(node.id)} onPaneClick={() => setSelectedId(null)}><Background gap={20} /><MiniMap pannable zoomable /><Controls /></ReactFlow> : <div className="empty-inline">No modules match this architecture query.</div>}</div><aside className="architecture-inspector" aria-live="polite">{selected ? <><span className="eyebrow">Selected module</span><h3>{selected.label}</h3><code>{selected.path}</code><dl><div><dt>Language</dt><dd>{selected.language}</dd></div><div><dt>Hotspot score</dt><dd>{selected.hotspot_score}</dd></div><div><dt>Inbound edges</dt><dd>{selected.inbound}</dd></div><div><dt>Outbound edges</dt><dd>{selected.outbound}</dd></div></dl></> : <><span className="eyebrow">Module inspector</span><h3>Explore the topology</h3><p>Click a node to see its path, language and coupling. The graph stays bounded for a responsive workspace.</p></>}</aside></div></section>{report.architecture.truncated && <div className="notice-banner">The graph is intentionally bounded for responsiveness; hotspot analysis still considers all supported source files.</div>}</div>
}
+32
View File
@@ -0,0 +1,32 @@
type BrandMarkProps = {
size?: number
label?: string
className?: string
}
/**
* RepoPulse's mark combines a bounded repository topology with a measured
* signal. It is deliberately geometric so it stays legible at favicon size.
*/
export function BrandMark({ size = 32, label = 'RepoPulse', className = '' }: BrandMarkProps) {
return (
<svg className={`brand-svg ${className}`} width={size} height={size} viewBox="0 0 32 32" role={label ? 'img' : undefined} aria-label={label || undefined} xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="currentColor" opacity=".14" />
<path d="M7 9.5h5.1l2.2 2.2H25" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7 22.5h5.1l2.2-2.2H25" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="7" cy="9.5" r="2" fill="currentColor" />
<circle cx="7" cy="22.5" r="2" fill="currentColor" />
<circle cx="25" cy="16" r="2" fill="currentColor" />
</svg>
)
}
export function BrandLockup({ compact = false }: { compact?: boolean }) {
return (
<span className="brand-lockup-copy">
<BrandMark size={compact ? 28 : 32} label="RepoPulse mark" />
{!compact && <span><strong>RepoPulse</strong><small>repository intelligence</small></span>}
</span>
)
}
@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DashboardView } from './DashboardView'
import { testRepository } from '../test-fixtures'
afterEach(() => cleanup())
describe('DashboardView', () => {
it('aggregates repository readiness and carries intelligence interactions into navigation', () => {
const blocked = testRepository('gitea:10', 'Jens/Blocked', { last_scan_health: 58, last_scan_readiness: 'BLOCKED', last_scan_added_findings: 2, last_scan_priority: 800, last_scan_primary_signal: { code: 'dependency.no-lockfile', category: 'Dependencies', severity: 'blocker', message: 'No lockfile was retained for the declared dependencies.', count: 1, guidance: { remediation_class: 'dependency reproducibility', confidence: 'high', why: 'The workspace has no retained package-manager lockfile.', recommended_action: 'Commit the package-manager lockfile for a repeatable build.', priority: 800, priority_rationale: 'Blocker signal.' } } })
const ready = testRepository('gitea:11', 'Jens/Ready', { last_scan_health: 96, last_scan_readiness: 'READY' })
const warning = testRepository('gitea:12', 'Jens/Warning', { last_scan_health: 85, last_scan_readiness: 'READY WITH WARNINGS' })
const onOpenRepository = vi.fn()
const onExploreReadiness = vi.fn()
render(<DashboardView repositories={[blocked, ready, warning]} pinnedIds={[ready.id]} onOpenRepository={onOpenRepository} onExploreReadiness={onExploreReadiness} onOpenExplorer={vi.fn()} />)
expect(screen.getByRole('heading', { name: '80/100 portfolio signal' })).toBeTruthy()
expect(screen.getByText('Why attention is needed')).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Remediation Center' })).toBeTruthy()
expect(screen.getAllByText('No lockfile was retained for the declared dependencies.')).toHaveLength(2)
expect(screen.getAllByText('Commit the package-manager lockfile for a repeatable build.')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'Open 1 blocked repositories in Explorer' }))
expect(onExploreReadiness).toHaveBeenCalledWith('blocked')
fireEvent.click(screen.getByRole('button', { name: /Open Jens\/Blocked/ }))
expect(onOpenRepository).toHaveBeenCalledWith(blocked)
})
})
+104
View File
@@ -0,0 +1,104 @@
import type { RepositorySummary } from '../api'
import { readinessLabel, repositoryReadiness, type Readiness } from '../catalog'
import { PortfolioAtlas } from './PortfolioAtlas'
import { RemediationCenter } from './RemediationCenter'
import { RepositoryVisual } from './RepositoryVisual'
type DashboardViewProps = {
repositories: RepositorySummary[]
pinnedIds: string[]
onOpenRepository: (repository: RepositorySummary) => void
onExploreReadiness: (readiness: Readiness) => void
onOpenExplorer: () => void
}
function relativeTime(value: string | null) {
if (!value) return 'Not scanned'
const difference = Date.now() - Date.parse(value)
if (Number.isNaN(difference)) return 'Unknown scan time'
if (difference < 3_600_000) return `${Math.max(1, Math.floor(difference / 60_000))}m ago`
if (difference < 86_400_000) return `${Math.floor(difference / 3_600_000)}h ago`
return `${Math.floor(difference / 86_400_000)}d ago`
}
function attentionReason(repository: RepositorySummary) {
if (repository.state === 'scanning') return 'Analysis is in progress — wait for the retained result before triage.'
if (repository.stale) return 'The retained scan is stale — refresh it before relying on the release signal.'
if (!repository.last_scan_at) return 'No retained scan exists yet — establish an evidence baseline.'
if (repository.last_scan_primary_signal) return repository.last_scan_primary_signal.message
if (repositoryReadiness(repository) === 'blocked') return `Release readiness is blocked at ${repository.last_scan_health ?? '—'} health.`
return `Warnings remain at ${repository.last_scan_health ?? '—'} health; review them before the next release.`
}
function remediationPath(repository: RepositorySummary) {
return repository.last_scan_primary_signal?.guidance?.recommended_action ?? 'Open the retained evidence and first safe action.'
}
function changeContext(repository: RepositorySummary) {
if (repository.last_scan_added_findings > 0) return `${repository.last_scan_added_findings} new signal${repository.last_scan_added_findings === 1 ? '' : 's'} since prior scan`
if (repository.last_scan_resolved_findings > 0) return `${repository.last_scan_resolved_findings} resolved since prior scan`
return repository.last_scan_health_delta == null ? 'baseline signal' : 'longstanding signal'
}
function triagePriority(repository: RepositorySummary) {
return repository.last_scan_priority ?? 0
}
function readinessDescription(readiness: Readiness) {
if (readiness === 'blocked') return 'needs intervention'
if (readiness === 'warnings') return 'needs review'
return 'ready to ship'
}
export function DashboardView({ repositories, pinnedIds, onOpenRepository, onExploreReadiness, onOpenExplorer }: DashboardViewProps) {
const portfolio = repositories.filter((repository) => !repository.archived)
const counts = portfolio.reduce<Record<Readiness, number>>((result, repository) => {
result[repositoryReadiness(repository)] += 1
return result
}, { ready: 0, warnings: 0, blocked: 0 })
const scored = portfolio.filter((repository) => repository.last_scan_health != null)
const averageHealth = scored.length ? Math.round(scored.reduce((sum, repository) => sum + (repository.last_scan_health ?? 0), 0) / scored.length) : 0
const attention = [...portfolio].filter((repository) => repositoryReadiness(repository) !== 'ready').sort((left, right) => triagePriority(right) - triagePriority(left) || (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1) || (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)).slice(0, 5)
const recent = [...portfolio].filter((repository) => repository.last_scan_at).sort((left, right) => (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)).slice(0, 4)
const pinned = pinnedIds.map((id) => portfolio.find((repository) => repository.id === id)).filter((repository): repository is RepositorySummary => Boolean(repository)).slice(0, 3)
const stale = portfolio.filter((repository) => repository.stale).length
const nextRepository = attention[0] ?? recent[0]
const improved = portfolio.filter((repository) => repository.last_scan_resolved_findings > repository.last_scan_added_findings || (repository.last_scan_health_delta ?? 0) > 0).length
const regressed = portfolio.filter((repository) => repository.last_scan_added_findings > 0 || (repository.last_scan_health_delta ?? 0) < 0).length
return (
<section className="page-content dashboard-page cockpit-page" aria-labelledby="dashboard-title">
<header className="dashboard-command">
<div className="command-copy"><span className="eyebrow">Portfolio command deck</span><h1 id="dashboard-title"><span>{averageHealth}</span><small>/100</small> portfolio signal</h1><p>{portfolio.length} retained repository signals · {counts.blocked + counts.warnings} require a deliberate decision · {stale} stale</p><div className="command-trend" aria-label={`${improved} improved repositories and ${regressed} repositories with new signals`}><span><i className="trend-up" />{improved} improving</span><span><i className="trend-watch" />{regressed} new signals</span></div></div>
<div className="command-pulse" aria-label={`${averageHealth} average portfolio health`}><svg viewBox="0 0 230 110" aria-hidden="true"><path className="pulse-field" d="M0 81C29 59 43 92 68 65s32-46 57-23 36 14 54-9 31-28 51-10" /><path className="pulse-line" d="M0 79h26l14-22 17 39 21-62 19 42h30l16-24 20 28h37" /><circle cx="203" cy="80" r="5" /></svg><strong>{averageHealth}</strong><span>health signal</span></div>
<dl className="command-context"><div><dt>Blockers</dt><dd>{counts.blocked}</dd></div><div><dt>Warnings</dt><dd>{counts.warnings}</dd></div><div><dt>Clear</dt><dd>{counts.ready}</dd></div></dl>
</header>
<nav className="dashboard-readiness-rail" aria-label="Portfolio readiness summary">
{(['blocked', 'warnings', 'ready'] as Readiness[]).map((readiness, index) => <button key={readiness} type="button" className={`readiness-rail-segment ${readiness}`} onClick={() => onExploreReadiness(readiness)} aria-label={`Open ${counts[readiness]} ${readinessLabel(readiness).toLowerCase()} repositories in Explorer`}><span>{String(index + 1).padStart(2, '0')} / {readinessLabel(readiness)}</span><strong>{counts[readiness]}</strong><small>{readinessDescription(readiness)} <b>→</b></small></button>)}
<button type="button" className="readiness-rail-segment all" onClick={onOpenExplorer}><span>04 / Catalog</span><strong>{portfolio.length}</strong><small>Open repository explorer <b>→</b></small></button>
</nav>
<div className="cockpit-focus-grid">
<section className="dashboard-section attention-section" aria-labelledby="attention-title">
<div className="section-heading"><div><span className="eyebrow">Triage, in priority order</span><h2 id="attention-title">Why attention is needed</h2></div><button className="text-button" type="button" onClick={() => onExploreReadiness('blocked')}>Open blocked lane</button></div>
{attention.length ? <div className="attention-list">{attention.map((repository, index) => <button type="button" className="attention-row" key={repository.id} onClick={() => onOpenRepository(repository)}><span className="attention-rank">{String(index + 1).padStart(2, '0')}</span><RepositoryVisual repository={repository} compact /><span className="attention-copy"><strong>{repository.full_name}</strong><small>{attentionReason(repository)}</small><span className="attention-context"><i>{repository.last_scan_primary_signal?.category ?? readinessLabel(repositoryReadiness(repository))}</i><em>{changeContext(repository)}</em></span></span><span className="attention-score"><b>{repository.last_scan_health ?? '—'}</b><small>{relativeTime(repository.last_scan_at)}</small></span></button>)}</div> : <p className="empty-inline">No repository needs attention right now.</p>}
</section>
<section className="dashboard-section next-move" aria-labelledby="next-move-title">
<span className="eyebrow">Recommended next move</span><h2 id="next-move-title">{nextRepository ? nextRepository.full_name : 'Portfolio is clear'}</h2>
{nextRepository ? <button type="button" className="next-move-action" onClick={() => onOpenRepository(nextRepository)}><RepositoryVisual repository={nextRepository} /><span><strong>{nextRepository.last_scan_health ?? '—'} health</strong><small>{attentionReason(nextRepository)}</small><em className="next-move-context"><i>{nextRepository.last_scan_primary_signal?.category ?? 'Portfolio signal'}</i>{remediationPath(nextRepository)}</em><b>Open workspace →</b></span></button> : <p className="muted">Keep the next immutable scan as the portfolio baseline.</p>}
</section>
</div>
<RemediationCenter repositories={portfolio} onOpenRepository={onOpenRepository} />
<PortfolioAtlas repositories={portfolio} onOpenRepository={onOpenRepository} />
<div className="dashboard-bottom-grid">
<section className="dashboard-section activity-section" aria-labelledby="recent-title"><div className="section-heading"><div><span className="eyebrow">Change feed</span><h2 id="recent-title">Recently scanned</h2></div><button className="text-button" type="button" onClick={onOpenExplorer}>Open explorer</button></div><div className="activity-rail">{recent.map((repository) => <button type="button" key={repository.id} className="activity-rail-item" onClick={() => onOpenRepository(repository)}><span className={`status-orb ${repositoryReadiness(repository)}`} /><span><strong>{repository.full_name}</strong><small>{repository.state === 'scanning' ? 'Analysis in progress' : `Retained scan ${relativeTime(repository.last_scan_at)}`} · {repository.default_branch}</small></span><b>{repository.last_scan_health ?? '—'}</b></button>)}</div></section>
<section className="dashboard-section pinned-section" aria-labelledby="pinned-title"><div className="section-heading"><div><span className="eyebrow">Your focus</span><h2 id="pinned-title">Pinned repositories</h2></div></div>{pinned.length ? <div className="pinned-grid">{pinned.map((repository) => <button key={repository.id} type="button" className="pinned-tile" onClick={() => onOpenRepository(repository)}><RepositoryVisual repository={repository} compact /><span><strong>{repository.name}</strong><small>{repository.last_scan_health ?? '—'} health · {readinessLabel(repositoryReadiness(repository))}</small></span><b>→</b></button>)}</div> : <div className="pin-empty"><span>Pin repositories in Explorer to keep active work in this command deck.</span><button className="text-button" type="button" onClick={onOpenExplorer}>Browse repositories</button></div>}</section>
</div>
</section>
)
}
@@ -0,0 +1,12 @@
import type { ScanReport } from '../api'
export function DependenciesView({ report }: { report: ScanReport }) {
const dependencySignals = [
['Declared dependencies', report.dependencies.declared_dependencies],
['Dependency manifests', report.dependencies.manifests.length],
['Lockfiles', report.dependencies.lockfiles.length],
['Unbounded versions', report.dependencies.unbounded_dependencies],
['Unpinned versions', report.dependencies.unpinned_dependencies],
]
return <div className="view-stack"><section className="section-card dependency-hero"><span className="eyebrow">Dependency intelligence</span><h2>{report.dependencies.declared_dependencies} declared dependencies</h2><p className="muted">Manifest and lockfile signals from the selected immutable repository revision.</p><div className="dependency-metrics">{dependencySignals.map(([label, value]) => <div key={label as string}><strong>{value}</strong><span>{label}</span></div>)}</div></section><section className="section-card"><div className="section-heading"><div><span className="eyebrow">Inventory</span><h3>Manifest coverage</h3></div></div>{report.dependencies.manifests.length ? <div className="manifest-list">{report.dependencies.manifests.map((manifest) => <code key={manifest}>{manifest}</code>)}</div> : <div className="empty-inline">No supported dependency manifest was detected in this snapshot.</div>}<div className="section-heading dependency-lock-heading"><div><span className="eyebrow">Reproducibility</span><h3>Lockfiles</h3></div></div>{report.dependencies.lockfiles.length ? <div className="manifest-list">{report.dependencies.lockfiles.map((lockfile) => <code key={lockfile}>{lockfile}</code>)}</div> : <div className="empty-inline">No lockfile was detected for this snapshot.</div>}</section></div>
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from 'react'
import type { Finding, ScanReport, Severity } from '../api'
function findingLocation(finding: Finding) {
if (!finding.path) return 'Repository-level'
return `${finding.path}${finding.line ? `:${finding.line}` : ''}`
}
export function FindingsView({ report }: { report: ScanReport }) {
const [severity, setSeverity] = useState<'all' | Severity>('all')
const [category, setCategory] = useState('all')
const [query, setQuery] = useState('')
const categories = useMemo(() => [...new Set(report.findings.map((item) => item.category))].sort(), [report.findings])
const suppressedCount = report.findings.filter((item) => item.suppressed).length
useEffect(() => {
if (category !== 'all' && !categories.includes(category)) setCategory('all')
}, [category, categories])
const findings = useMemo(() => {
const needle = query.trim().toLowerCase()
return report.findings.filter((item) => {
if (severity !== 'all' && item.severity !== severity) return false
if (category !== 'all' && item.category !== category) return false
if (!needle) return true
return `${item.message} ${item.path ?? ''} ${item.code}`.toLowerCase().includes(needle)
})
}, [report.findings, severity, category, query])
return (
<section className="section-card">
<div className="section-heading findings-heading">
<div><span className="eyebrow">Findings</span><h3>Explain every deduction</h3></div>
<span className="section-note">Showing {findings.length} of {report.findings.length}. {suppressedCount ? `${suppressedCount} accepted-risk finding(s) suppressed. ` : ''}Potential secret values are intentionally redacted.</span>
</div>
<div className="finding-filters">
<input aria-label="Filter findings" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter message, code or path" />
<select aria-label="Filter findings by severity" value={severity} onChange={(event) => setSeverity(event.target.value as 'all' | Severity)}>
<option value="all">All severities</option>
<option value="blocker">Blockers</option>
<option value="warning">Warnings</option>
<option value="info">Info</option>
</select>
<select aria-label="Filter findings by category" value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="all">All categories</option>
{categories.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="findings-list">
{findings.map((finding, index) => (
<details className={`finding ${finding.suppressed ? 'suppressed' : ''}`} key={`${finding.code}-${finding.path}-${finding.line}-${index}`}>
<summary className="finding-summary">
<span className={`severity severity-${finding.severity}`}>{finding.suppressed ? 'accepted' : finding.severity}</span>
<span className="finding-copy">
<strong>{finding.message}</strong>
<span>{finding.category} · {finding.code}{finding.path ? ` · ${findingLocation(finding)}` : ''}</span>
</span>
<span className="deduction">{finding.deduction ? `−${finding.deduction}` : 'info'}</span>
</summary>
<div className="finding-details">
<dl className="finding-detail-grid">
<div><dt>Severity</dt><dd>{finding.suppressed ? 'Accepted risk' : finding.severity}</dd></div>
<div><dt>Rule</dt><dd><code>{finding.code}</code></dd></div>
<div><dt>Category</dt><dd>{finding.category}</dd></div>
<div><dt>Location</dt><dd><code>{findingLocation(finding)}</code></dd></div>
<div><dt>Deduction</dt><dd>{finding.deduction ? `−${finding.deduction} points` : 'Informational'}</dd></div>
<div><dt>Status</dt><dd>{finding.suppressed ? 'Suppressed' : 'Active'}</dd></div>
</dl>
</div>
</details>
))}
{!findings.length && <div className="empty-inline">No findings match the current filters.</div>}
</div>
</section>
)
}
+72
View File
@@ -0,0 +1,72 @@
import type { ScanComparison, ScanSummary, TrendPoint } from '../api'
export function HistoryView({ trend, comparison, scanSummaries = [], currentScanId, onSelectScan }: { trend: TrendPoint[]; comparison: ScanComparison | null; scanSummaries?: ScanSummary[]; currentScanId?: string; onSelectScan?: (summary: ScanSummary) => void }) {
return (
<div className="view-stack">
<section className="section-card">
<div className="section-heading">
<div><span className="eyebrow">History</span><h3>Repository health over time</h3></div>
<span className="section-note">Each bar is one persisted scan snapshot.</span>
</div>
{trend.length ? (
<div className="trend-chart" aria-label="Repository health trend">
{trend.map((point) => (
<div className="trend-column" key={point.scan_id} title={`${new Date(point.scanned_at).toLocaleString()} · ${point.health_score}/100`}>
<span>{point.health_score}</span>
<div className="trend-track"><i style={{ height: `${Math.max(4, point.health_score)}%` }} /></div>
<small>{new Date(point.scanned_at).toLocaleDateString()}</small>
</div>
))}
</div>
) : <div className="empty-inline">Run multiple scans of the same repository to build a trend.</div>}
</section>
<section className="comparison-grid">
<article className="section-card">
<span className="eyebrow">Since previous scan</span>
<h3>{comparison?.health_score_delta == null ? 'No baseline yet' : `${comparison.health_score_delta >= 0 ? '+' : ''}${comparison.health_score_delta} health points`}</h3>
{comparison?.previous_scan_id ? (
<div className="delta-grid">
{Object.entries(comparison.category_score_deltas).map(([name, value]) => (
<div key={name}><span>{name}</span><strong className={value > 0 ? 'positive' : value < 0 ? 'negative' : ''}>{value > 0 ? '+' : ''}{value}</strong></div>
))}
</div>
) : <p className="muted">The first scan establishes the baseline for comparisons.</p>}
</article>
<article className="section-card">
<span className="eyebrow">Finding delta</span>
<h3>{comparison?.added_findings.length ?? 0} added · {comparison?.resolved_findings.length ?? 0} resolved</h3>
<div className="delta-findings">
{comparison?.resolved_findings.slice(0, 6).map((item, index) => (
<div className="resolved" key={`resolved-${item.code}-${index}`}>Resolved · {item.message}</div>
))}
{comparison?.added_findings.slice(0, 6).map((item, index) => (
<div className="added" key={`added-${item.code}-${index}`}>Added · {item.message}</div>
))}
{comparison?.previous_scan_id && !comparison.added_findings.length && !comparison.resolved_findings.length && (
<div className="empty-inline">No finding-level changes since the previous scan.</div>
)}
</div>
</article>
</section>
<section className="section-card scan-run-card">
<div className="section-heading">
<div><span className="eyebrow">Persisted snapshots</span><h3>Scan runs</h3></div>
<span className="section-note">Select a revision to inspect its report.</span>
</div>
{scanSummaries.length ? (
<div className="scan-run-list">
{scanSummaries.map((item) => (
<button type="button" className={`scan-run-row ${item.id === currentScanId ? 'selected' : ''}`} key={item.id} onClick={() => onSelectScan?.(item)} disabled={!onSelectScan}>
<span><strong>{item.actual_revision ? item.actual_revision.slice(0, 10) : 'not acquired'}</strong><small>{new Date(item.scanned_at).toLocaleString()} · {item.trigger} · {item.findings} finding(s)</small></span>
<strong>{item.health_score}</strong>
</button>
))}
</div>
) : <div className="empty-inline">Analyze this repository to start its history.</div>}
</section>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { ScanReport } from '../api'
export function HotspotsView({ report }: { report: ScanReport }) {
return (
<section className="section-card">
<div className="section-heading">
<div>
<span className="eyebrow">Engineering hotspots</span>
<h3>Where maintenance risk concentrates</h3>
</div>
<span className="section-note">Score combines size, debt markers, recent Git churn and local coupling.</span>
</div>
<div className="hotspot-list">
{report.hotspots.map((hotspot, index) => (
<article className="hotspot-row" key={hotspot.path}>
<div className="hotspot-rank">{index + 1}</div>
<div className="hotspot-main">
<div className="hotspot-title"><strong>{hotspot.path}</strong><span>{hotspot.language}</span></div>
<div className="hotspot-reasons">
{hotspot.reasons.length ? hotspot.reasons.map((reason) => <span key={reason}>{reason}</span>) : <span>No dominant risk signal</span>}
</div>
<div className="bar hotspot-bar"><i style={{ width: `${hotspot.score}%` }} /></div>
</div>
<div className="hotspot-metrics">
<strong>{hotspot.score}</strong>
<small>{hotspot.lines_of_code} LOC</small>
<small>{hotspot.churn} churn</small>
<small>{hotspot.inbound + hotspot.outbound} edges</small>
</div>
</article>
))}
{!report.hotspots.length && <div className="empty-inline">No supported source files were available for hotspot scoring.</div>}
</div>
</section>
)
}
+71
View File
@@ -0,0 +1,71 @@
import type { ScanComparison, ScanReport } from '../api'
export function OverviewView({ report, comparison, onShowFindings }: { report: ScanReport; comparison: ScanComparison | null; onShowFindings?: () => void }) {
const debt = report.metrics.todo + report.metrics.fixme + report.metrics.hack + report.metrics.xxx
const activeFindings = report.findings.filter((item) => !item.suppressed)
const blockers = activeFindings.filter((item) => item.severity === 'blocker').length
const warnings = activeFindings.filter((item) => item.severity === 'warning').length
const languageTotal = Object.values(report.languages).reduce((sum, value) => sum + value, 0)
const primaryFinding = activeFindings.find((item) => item.severity === 'blocker') ?? activeFindings.find((item) => item.severity === 'warning')
const healthDelta = comparison?.health_score_delta
const readinessState = blockers ? 'blocked' : warnings ? 'warnings' : 'ready'
const readinessCopy = blockers ? `${blockers} blocker(s) need attention before release.` : warnings ? `${warnings} warning(s) are worth reviewing before release.` : 'No active blockers or warnings in this snapshot.'
const lowSignal = report.metrics.source_files === 0 && report.metrics.lines_of_code === 0 && report.metrics.test_files === 0 && report.dependencies.declared_dependencies === 0
const topFindings = activeFindings.slice().sort((left, right) => (left.severity === 'blocker' ? -1 : 0) - (right.severity === 'blocker' ? -1 : 0) || right.deduction - left.deduction).slice(0, 4)
return (
<div className="view-stack">
{lowSignal && <div className="notice-banner low-signal-notice" role="status">This repository contains little or no analyzable source code. Zero-value metrics are kept as context while readiness and the next scan remain the useful signals.</div>}
<section className={`metric-grid ${lowSignal ? 'low-signal' : ''}`} aria-label="Repository metrics">
<article><span>Source files</span><strong>{report.metrics.source_files.toLocaleString()}</strong><small>Analyzed source footprint</small></article>
<article><span>Lines of code</span><strong>{report.metrics.lines_of_code.toLocaleString()}</strong><small>Across supported files</small></article>
<article><span>Tests</span><strong>{report.metrics.test_files.toLocaleString()}</strong><small>{report.tests.frameworks.length ? report.tests.frameworks.join(', ') : 'No test framework detected'}</small></article>
<article><span>Dependencies</span><strong>{report.dependencies.declared_dependencies.toLocaleString()}</strong><small>{report.dependencies.manifests.length ? `${report.dependencies.manifests.length} manifest(s)` : 'No manifests detected'}</small></article>
<article><span>Debt markers</span><strong>{debt.toLocaleString()}</strong><small>{debt ? 'TODO, FIXME and related markers' : 'No markers detected'}</small></article>
<article><span>Findings</span><strong>{activeFindings.length.toLocaleString()}</strong><small>{report.findings.length - activeFindings.length} suppressed</small></article>
</section>
<section className="insight-grid" aria-label="Repository insights">
<article className={`insight-card insight-${readinessState}`}><span className="eyebrow">Readiness signal</span><strong>{report.release_readiness}</strong><p>{readinessCopy}</p></article>
<article className="insight-card"><span className="eyebrow">What to fix first</span><strong>{primaryFinding ? primaryFinding.message : 'No active finding requires attention'}</strong><p>{primaryFinding ? `${primaryFinding.category} · ${primaryFinding.code}${primaryFinding.path ? ` · ${primaryFinding.path}` : ''}` : 'Keep the next scan as your baseline.'}</p></article>
<article className="insight-card"><span className="eyebrow">Changed since previous</span><strong>{healthDelta == null ? 'Baseline snapshot' : `${healthDelta >= 0 ? '+' : ''}${healthDelta} health points`}</strong><p>{healthDelta == null ? 'A later scan will unlock comparison context.' : `${comparison?.added_findings.length ?? 0} added · ${comparison?.resolved_findings.length ?? 0} resolved finding(s).`}</p></article>
</section>
<section className="overview-intelligence">
<article className="section-card change-brief">
<div className="section-heading"><div><span className="eyebrow">Since previous revision</span><h3>What changed?</h3></div>{healthDelta != null && <strong className={healthDelta >= 0 ? 'positive' : 'negative'}>{healthDelta >= 0 ? '+' : ''}{healthDelta} health</strong>}</div>
{comparison?.previous_scan_id ? <div className="change-grid"><span><b>{comparison.health_score_delta == null ? '—' : `${comparison.health_score_delta >= 0 ? '+' : ''}${comparison.health_score_delta}`}</b>health</span><span><b>{comparison.added_findings.length ? `+${comparison.added_findings.length}` : '0'}</b>findings added</span><span><b>{comparison.resolved_findings.length ? `−${comparison.resolved_findings.length}` : '0'}</b>findings resolved</span><span><b>{comparison.metric_deltas.lines_of_code == null ? '—' : `${comparison.metric_deltas.lines_of_code >= 0 ? '+' : ''}${comparison.metric_deltas.lines_of_code}`}</b>lines of code</span></div> : <p className="muted">This retained snapshot establishes the comparison baseline for the next immutable revision.</p>}
</article>
<article className="section-card top-findings-card">
<div className="section-heading"><div><span className="eyebrow">Priority review</span><h3>Top findings</h3></div>{onShowFindings ? <button className="text-button" type="button" onClick={onShowFindings}>View all {activeFindings.length} findings</button> : <span className="section-note">All {activeFindings.length} findings are available in Findings.</span>}</div>
{topFindings.length ? <div className="top-findings-list">{topFindings.map((finding) => <div key={`${finding.code}-${finding.fingerprint ?? finding.path}`}><span className={`severity severity-${finding.severity}`}>{finding.severity}</span><span><strong>{finding.message}</strong><small>{finding.category}{finding.path ? ` · ${finding.path}` : ''}</small></span><b>−{finding.deduction}</b></div>)}</div> : <div className="empty-inline">No active findings need attention in this snapshot.</div>}
</article>
</section>
<section className="overview-columns">
<article className="section-card">
<div className="section-heading"><div><span className="eyebrow">Health model</span><h3>Category score rows</h3></div>{healthDelta != null && <span className={`delta ${healthDelta >= 0 ? 'positive' : 'negative'}`}>{healthDelta >= 0 ? '+' : ''}{healthDelta} since previous</span>}</div>
<div className="score-grid">
{Object.entries(report.category_scores).map(([name, value]) => (
<article className="score-card" key={name}>
<div><span>{name}</span><strong>{value}</strong></div>
<div className="bar"><i style={{ width: `${value}%` }} /></div>
{comparison?.category_score_deltas[name] != null && comparison.category_score_deltas[name] !== 0 && <small className={comparison.category_score_deltas[name] > 0 ? 'positive' : 'negative'}>{comparison.category_score_deltas[name] > 0 ? '+' : ''}{comparison.category_score_deltas[name]} since previous</small>}
</article>
))}
</div>
</article>
<article className="section-card release-gate-card"><span className="eyebrow">Release gate</span><h3>{report.release_gate?.status ?? report.release_readiness}</h3><div className="gate-list">{report.release_gate?.checks.length ? report.release_gate.checks.map((check) => <div className="gate-check" key={check.key}><span className={`gate-state gate-${check.state}`}>{check.state}</span><div><strong>{check.label}</strong><small>{check.detail}</small></div></div>) : <div className="empty-inline">No release-gate checks were returned for this snapshot.</div>}</div></article>
</section>
<section className="overview-columns signals-columns">
<article className="section-card"><span className="eyebrow">Languages</span><h3>Code footprint</h3><div className="language-list">{Object.entries(report.languages).slice(0, 12).map(([language, lines]) => <div className="language" key={language}><div><span>{language}</span><strong>{lines.toLocaleString()} LOC</strong></div><div className="bar"><i style={{ width: `${languageTotal ? (lines / languageTotal) * 100 : 0}%` }} /></div></div>)}{!Object.keys(report.languages).length && <div className="empty-inline">No language signals were detected.</div>}</div></article>
<div className="signal-stack">
<article className="section-card signal-card"><span className="eyebrow">Testing & CI</span><h3>{report.tests.frameworks.length ? report.tests.frameworks.join(', ') : 'Testing signals unavailable'}</h3><dl className="compact-dl"><div><dt>Test/source ratio</dt><dd>{report.tests.test_source_ratio != null ? `${report.tests.test_source_ratio}%` : 'Not measured'}</dd></div><div><dt>CI workflows</dt><dd>{report.tests.ci_workflows.length || 'None detected'}</dd></div><div><dt>Coverage artifacts</dt><dd>{report.tests.coverage_artifacts.length || 'None detected'}</dd></div></dl></article>
<article className="section-card signal-card"><span className="eyebrow">Quick signals</span><h3>Repository context</h3><dl className="compact-dl"><div><dt>Git commits</dt><dd>{report.git.commit_count ?? 'Unavailable'}</dd></div><div><dt>Contributors</dt><dd>{report.git.contributor_count ?? 'Unavailable'}</dd></div><div><dt>Manifests</dt><dd>{report.dependencies.manifests.length || 'None detected'}</dd></div><div><dt>Lockfiles</dt><dd>{report.dependencies.lockfiles.length || 'None detected'}</dd></div><div><dt>Graph edges</dt><dd>{report.architecture.total_edges || report.architecture.edges.length || 'None detected'}</dd></div></dl></article>
</div>
</section>
</div>
)
}
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PortfolioAtlas } from './PortfolioAtlas'
import { testRepository } from '../test-fixtures'
afterEach(() => cleanup())
describe('PortfolioAtlas', () => {
it('keeps every repository operable while using the bounded compact detail mode at portfolio scale', () => {
const repositories = Array.from({ length: 500 }, (_, index) => testRepository(`gitea:${index}`, `Jens/Repo-${index}`, {
last_scan_health: 40 + (index % 60),
last_scan_readiness: index % 3 === 0 ? 'BLOCKED' : index % 3 === 1 ? 'READY WITH WARNINGS' : 'READY',
}))
const onOpenRepository = vi.fn()
const { container } = render(<PortfolioAtlas repositories={repositories} onOpenRepository={onOpenRepository} />)
expect(container.querySelector('.portfolio-atlas')?.getAttribute('data-density')).toBe('compact')
expect(screen.getAllByRole('button', { name: /Open Jens\/Repo-/ })).toHaveLength(500)
fireEvent.click(screen.getByRole('button', { name: /Open Jens\/Repo-0/ }))
expect(onOpenRepository).toHaveBeenCalledWith(repositories[0])
})
})
@@ -0,0 +1,75 @@
import type { CSSProperties } from 'react'
import type { RepositorySummary } from '../api'
import { readinessLabel, repositoryReadiness, type Readiness } from '../catalog'
type PortfolioAtlasProps = {
repositories: RepositorySummary[]
onOpenRepository: (repository: RepositorySummary) => void
}
const laneOrder: Readiness[] = ['blocked', 'warnings', 'ready']
const laneCopy: Record<Readiness, { title: string; description: string }> = {
blocked: { title: 'Intervention lane', description: 'Release-blocking repository signals' },
warnings: { title: 'Review lane', description: 'Warnings worth resolving before the next release' },
ready: { title: 'Clear lane', description: 'Repositories with a clear release signal' },
}
function scanRhythm(repository: RepositorySummary) {
if (repository.state === 'scanning') return 'scan in progress'
if (repository.stale) return 'stale scan'
if (!repository.last_scan_at) return 'not scanned'
const difference = Date.now() - Date.parse(repository.last_scan_at)
if (Number.isNaN(difference)) return 'scan time unknown'
if (difference < 3_600_000) return 'scanned this hour'
if (difference < 86_400_000) return `scanned ${Math.floor(difference / 3_600_000)}h ago`
return `scanned ${Math.floor(difference / 86_400_000)}d ago`
}
function signalStrength(repository: RepositorySummary) {
return Math.max(16, Math.min(100, repository.last_scan_health ?? 36))
}
export function PortfolioAtlas({ repositories, onOpenRepository }: PortfolioAtlasProps) {
const ordered = [...repositories].sort((left, right) => {
const lane = laneOrder.indexOf(repositoryReadiness(left)) - laneOrder.indexOf(repositoryReadiness(right))
return lane || (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1) || left.full_name.localeCompare(right.full_name)
})
const density = ordered.length > 160 ? 'compact' : ordered.length > 96 ? 'dense' : 'comfortable'
return (
<section className="portfolio-atlas dashboard-section" aria-labelledby="atlas-title" data-density={density}>
<div className="section-heading atlas-heading">
<div><span className="eyebrow">Portfolio topology</span><h2 id="atlas-title">Repository signal landscape</h2></div>
<span className="section-note">{ordered.length} repositories · every signal opens its workspace</span>
</div>
<div className="atlas-field">
<svg className="atlas-contours" viewBox="0 0 1200 330" aria-hidden="true" preserveAspectRatio="none"><path d="M-20 270C118 216 190 318 335 246S525 172 656 230s250 38 390-55 154 3 210-38" /><path d="M-30 178c127-71 214 32 350-44s283 9 425-53 248 24 478-72" /><path d="M-10 90c156 27 230-67 390-8s209-18 362 24 252-56 475-13" /></svg>
<div className="atlas-bands">
{laneOrder.map((lane, laneIndex) => {
const laneRepositories = ordered.filter((repository) => repositoryReadiness(repository) === lane)
const copy = laneCopy[lane]
return <section className={`atlas-band ${lane}`} key={lane} aria-labelledby={`atlas-${lane}-title`} data-lane={lane}>
<header><i className="atlas-lane-beacon" aria-hidden="true" /><span>{String(laneIndex + 1).padStart(2, '0')} / {copy.title}</span><strong id={`atlas-${lane}-title`}>{laneRepositories.length}</strong><small>{copy.description}</small></header>
<div className="atlas-river"><ol className="atlas-tiles" aria-label={`${readinessLabel(lane)} repositories`}>
{laneRepositories.map((repository, index) => {
const health = repository.last_scan_health ?? 0
const style = { '--signal-strength': `${signalStrength(repository)}%`, '--signal-index': index, '--signal-phase': index % 7 } as CSSProperties
const primary = repository.last_scan_primary_signal
const signalDetail = primary ? `${primary.count} ${primary.category.toLowerCase()} signal${primary.count === 1 ? '' : 's'}` : 'no primary finding retained'
return <li key={repository.id}><button type="button" className={`atlas-repository ${repository.stale ? 'stale' : ''} ${repository.state === 'scanning' ? 'scanning' : ''}`} onClick={() => onOpenRepository(repository)} style={style} aria-label={`Open ${repository.full_name}: ${health || 'not scanned'} health, ${readinessLabel(lane)}, ${signalDetail}, ${scanRhythm(repository)}`}>
<span className="atlas-repository-orbit" aria-hidden="true" />
<span className="atlas-repository-name">{repository.name.slice(0, 3).toUpperCase()}</span>
<span className="atlas-repository-health">{health || '—'}</span>
<i aria-hidden="true" />
</button></li>
})}
</ol></div>
</section>
})}
</div>
</div>
<p className="atlas-legend"><span><i className="blocked" />Health bar height follows the retained score.</span><span><i className="stale" />A dashed frame means the scan needs refreshing.</span><span><i className="scanning" />A live ring means analysis is in progress.</span></p>
</section>
)
}
@@ -0,0 +1,49 @@
import type { RepositorySummary } from '../api'
import { repositoryReadiness } from '../catalog'
type RemediationCenterProps = {
repositories: RepositorySummary[]
onOpenRepository: (repository: RepositorySummary) => void
}
export function RemediationCenter({ repositories, onOpenRepository }: RemediationCenterProps) {
const candidates = [...repositories]
.filter((repository) => !repository.archived && repository.last_scan_primary_signal && repositoryReadiness(repository) !== 'ready')
.sort((left, right) => (right.last_scan_priority ?? 0) - (left.last_scan_priority ?? 0) || left.full_name.localeCompare(right.full_name))
.slice(0, 5)
if (!candidates.length) return null
return (
<section className="dashboard-section remediation-center" aria-labelledby="remediation-center-title">
<div className="section-heading">
<div>
<span className="eyebrow">Backend-ranked candidates</span>
<h2 id="remediation-center-title">Remediation Center</h2>
</div>
<span className="remediation-live">retained evidence</span>
</div>
<ol className="remediation-list">
{candidates.map((repository) => {
const signal = repository.last_scan_primary_signal
const guidance = signal?.guidance
if (!signal) return null
return (
<li key={repository.id}>
<button type="button" className="remediation-row" onClick={() => onOpenRepository(repository)}>
<span className="remediation-priority" aria-label={`Priority ${repository.last_scan_priority ?? 0}`}>{repository.last_scan_priority ?? 0}</span>
<span className="remediation-copy">
<strong>{repository.full_name}</strong>
<small>{guidance?.remediation_class ?? signal.category} · {guidance?.confidence ?? 'review'} confidence</small>
<em>{guidance?.why ?? signal.message}</em>
<b>{guidance?.recommended_action ?? 'Open the retained evidence and select the smallest safe next action.'}</b>
</span>
<span className="remediation-arrow" aria-hidden="true">→</span>
</button>
</li>
)
})}
</ol>
</section>
)
}
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { RepositoryVisual } from './RepositoryVisual'
import { testRepository } from '../test-fixtures'
afterEach(() => cleanup())
describe('RepositoryVisual', () => {
it('renders a stable, labelled fallback fingerprint from repository data', () => {
const repository = testRepository('gitea:23', 'Jens/SignalMap', { last_scan_revision: 'a-repeatable-revision' })
const { container } = render(<><RepositoryVisual repository={repository} /><RepositoryVisual repository={repository} /></>)
const visuals = screen.getAllByRole('img', { name: /SignalMap repository fingerprint/ })
expect(visuals).toHaveLength(2)
expect(container.querySelectorAll('.repository-visual svg')[0].innerHTML).toBe(container.querySelectorAll('.repository-visual svg')[1].innerHTML)
})
})
@@ -0,0 +1,100 @@
import type { RepositorySummary, ScanReport } from '../api'
import { readinessLabel, repositoryReadiness } from '../catalog'
type RepositoryVisualProps = {
repository: RepositorySummary
report?: ScanReport | null
compact?: boolean
className?: string
}
type VisualNode = { x: number; y: number; radius: number; hot: boolean; weight: number }
function hash(seed: string) {
let result = 2166136261
for (let index = 0; index < seed.length; index += 1) {
result ^= seed.charCodeAt(index)
result = Math.imul(result, 16777619)
}
return result >>> 0
}
function random(seed: number) {
let value = seed >>> 0
return () => {
value += 0x6d2b79f5
let output = value
output = Math.imul(output ^ (output >>> 15), output | 1)
output ^= output + Math.imul(output ^ (output >>> 7), output | 61)
return ((output ^ (output >>> 14)) >>> 0) / 4294967296
}
}
function nodesFor(repository: RepositorySummary, report?: ScanReport | null): VisualNode[] {
const source = report?.architecture.nodes
if (source?.length) {
return [...source]
.sort((left, right) => right.hotspot_score - left.hotspot_score || (right.inbound + right.outbound) - (left.inbound + left.outbound))
.slice(0, 14)
.map((node, index, nodes) => {
const weight = node.inbound + node.outbound + 1
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2
const ring = 24 + Math.min(17, weight * 1.7)
return { x: 80 + Math.cos(angle) * ring, y: 48 + Math.sin(angle) * ring * .74, radius: 2.5 + Math.min(5.4, Math.sqrt(weight) * .95), hot: node.hotspot_score >= 55, weight }
})
}
const take = random(hash(`${repository.id}:${repository.last_scan_revision ?? repository.latest_revision ?? ''}`))
const health = repository.last_scan_health ?? 54
const count = 7 + Math.floor(take() * 6)
return Array.from({ length: count }, (_, index) => {
const angle = (index / count) * Math.PI * 2 + take() * .44
const ring = 20 + take() * 28
return { x: 80 + Math.cos(angle) * ring, y: 48 + Math.sin(angle) * ring * .72, radius: 2.4 + take() * 3.8, hot: index === 0 || (health < 70 && index % 3 === 0), weight: 1 + Math.round(take() * 6) }
})
}
function primaryLanguage(report?: ScanReport | null) {
return Object.entries(report?.languages ?? {}).sort((left, right) => right[1] - left[1])[0]?.[0]
}
/** A deterministic project illustration driven by real architecture when available. */
export function RepositoryVisual({ repository, report, compact = false, className = '' }: RepositoryVisualProps) {
const nodes = nodesFor(repository, report)
const readiness = repositoryReadiness(repository)
const architectureBacked = Boolean(report?.architecture.nodes.length)
const seed = hash(repository.id).toString(36)
const health = repository.last_scan_health ?? 0
const language = primaryLanguage(report)
const healthLine = 65 - Math.max(0, Math.min(100, health)) * .3
const label = `${repository.full_name} ${architectureBacked ? 'architecture-backed' : 'repository'} fingerprint, ${readinessLabel(readiness)} readiness${language ? `, ${language} primary language` : ''}`
const core = nodes[0] ?? { x: 80, y: 48, radius: 4, hot: false, weight: 1 }
const nodeLinks = nodes.slice(1).map((node, index) => ({ from: nodes[index % Math.max(1, nodes.length - 1)] ?? core, to: node }))
return (
<div className={`repository-visual ${compact ? 'compact' : ''} state-${readiness} ${architectureBacked ? 'architecture-backed' : 'catalog-backed'} ${className}`} aria-label={label} role="img">
<svg viewBox="0 0 160 96" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={`visual-wash-${seed}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity=".3" /><stop offset="1" stopColor="currentColor" stopOpacity="0" /></linearGradient>
<linearGradient id={`visual-flow-${seed}`} x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="currentColor" stopOpacity="0" /><stop offset=".5" stopColor="currentColor" stopOpacity=".78" /><stop offset="1" stopColor="currentColor" stopOpacity=".08" /></linearGradient>
</defs>
<rect className="visual-wash" x="1" y="1" width="158" height="94" rx="13" fill={`url(#visual-wash-${seed})`} />
<path className="visual-gridline visual-gridline-one" d="M10 19H150M10 48H150M10 77H150" fill="none" stroke="currentColor" strokeOpacity=".07" strokeWidth=".7" />
<ellipse className="visual-orbit orbit-one" cx={core.x} cy={core.y} rx="26" ry="14" fill="none" stroke="currentColor" strokeOpacity=".12" strokeWidth=".9" strokeDasharray="2 5" />
<ellipse className="visual-orbit orbit-two" cx={core.x} cy={core.y} rx="39" ry="24" fill="none" stroke="currentColor" strokeOpacity=".08" strokeWidth=".8" />
<path className="visual-contour contour-one" d="M6 71C34 46 42 83 73 60S119 31 154 46" fill="none" stroke="currentColor" strokeOpacity=".17" strokeWidth="1" />
<path className="visual-contour contour-two" d="M-3 30C34 12 52 42 86 24S132 18 164 34" fill="none" stroke="currentColor" strokeOpacity=".1" strokeWidth="1" strokeDasharray="3 5" />
{nodeLinks.map(({ from, to }, index) => { const bend = index % 2 ? -7 : 7; return <path className="visual-link" key={`${from.x}-${to.x}-${index}`} d={`M${from.x} ${from.y} Q${(from.x + to.x) / 2 + bend} ${(from.y + to.y) / 2 - bend} ${to.x} ${to.y}`} fill="none" stroke={`url(#visual-flow-${seed})`} strokeWidth={Math.min(1.8, .65 + to.weight * .1)} /> })}
<path className="visual-health-line" d={`M7 ${healthLine + 12} C34 ${healthLine - 8}, 50 ${healthLine + 8}, 78 ${healthLine} S124 ${healthLine - 12}, 153 ${healthLine - 4}`} fill="none" stroke="currentColor" strokeOpacity=".82" strokeWidth="1.35" />
{nodes.map((node, index) => <g className={`visual-node ${node.hot ? 'hot' : ''}`} key={`${node.x}-${node.y}-${index}`}><circle cx={node.x} cy={node.y} r={node.radius + (node.hot ? 2.3 : 1.2)} fill="currentColor" fillOpacity={node.hot ? '.12' : '.07'} /><circle cx={node.x} cy={node.y} r={node.radius} fill="currentColor" fillOpacity={node.hot ? '.94' : '.63'} /></g>)}
<circle className="visual-core-halo" cx={core.x} cy={core.y} r="10.5" fill="none" stroke="currentColor" strokeOpacity=".25" strokeWidth="1" />
<circle className="visual-core" cx={core.x} cy={core.y} r="5.2" fill="var(--surface-strong)" stroke="currentColor" strokeWidth="1.6" />
<circle className="visual-core-beacon" cx={core.x} cy={core.y} r="2" fill="currentColor" />
<path d={`M${core.x - 3.4} ${core.y}h1.8l1.45-3.1 2.05 6.2 1.5-3.1h3.4`} fill="none" stroke="currentColor" strokeWidth="1.45" strokeLinecap="round" strokeLinejoin="round" />
<rect className="visual-state-bar" x="8" y="84" width={`${Math.max(12, health * 1.42)}`} height="2" rx="1" fill="currentColor" fillOpacity=".85" />
</svg>
{!compact && <span className="visual-caption" aria-hidden="true"><i>{architectureBacked ? 'ARCH' : 'SIGNAL'}</i><b>{language ?? `${health || '—'} health`}</b></span>}
{repository.state === 'scanning' && <span className="visual-scan-pulse" aria-hidden="true" />}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import type { ScanSummary } from '../api'
export function ScanRunsView({ scanSummaries, currentScanId, onSelectScan }: { scanSummaries: ScanSummary[]; currentScanId?: string; onSelectScan: (summary: ScanSummary) => void }) {
return <section className="section-card scan-runs-workspace"><div className="section-heading"><div><span className="eyebrow">Immutable history</span><h2>Scan runs</h2></div><span className="section-note">Select a revision to open the retained report.</span></div>{scanSummaries.length ? <div className="scan-run-list">{scanSummaries.map((item) => <button type="button" className={`scan-run-row ${item.id === currentScanId ? 'selected' : ''}`} key={item.id} onClick={() => onSelectScan(item)}><span><strong>{item.actual_revision ? item.actual_revision.slice(0, 10) : 'not acquired'}</strong><small>{new Date(item.scanned_at).toLocaleString()} · {item.trigger} · {item.findings} finding(s) · {Math.round(item.duration_ms)}ms</small></span><span className={`run-readiness ${item.release_readiness.toLowerCase().includes('block') ? 'blocked' : item.release_readiness.toLowerCase().includes('warning') ? 'warnings' : 'ready'}`}>{item.release_readiness}</span><strong>{item.health_score}</strong></button>)}</div> : <div className="empty-inline">Analyze this repository to create its immutable scan history.</div>}</section>
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { parseRoute, routePath } from './navigation'
describe('client navigation', () => {
it('round-trips repository identities and detail views', () => {
const path = routePath({ page: 'repositories', repositoryId: 'gitea:64', view: 'findings' })
expect(path).toBe('/repositories/gitea%3A64/findings')
expect(parseRoute(path)).toEqual({ page: 'repositories', repositoryId: 'gitea:64', view: 'findings' })
})
it('maps global destinations and safe repository defaults', () => {
expect(parseRoute('/')).toEqual({ page: 'dashboard' })
expect(routePath({ page: 'dashboard' })).toBe('/')
expect(parseRoute('/activity')).toEqual({ page: 'activity' })
expect(parseRoute('/repositories')).toEqual({ page: 'repositories' })
expect(parseRoute('/repositories/gitea%3A64/unknown')).toEqual({ page: 'repositories', repositoryId: 'gitea:64', view: 'overview' })
expect(routePath({ page: 'settings' })).toBe('/settings')
})
})
+32
View File
@@ -0,0 +1,32 @@
export type DetailView = 'overview' | 'architecture' | 'hotspots' | 'dependencies' | 'findings' | 'history' | 'scans'
export type Route = { page: 'dashboard' | 'repositories' | 'activity' | 'queue' | 'settings'; repositoryId?: string; view?: DetailView }
export const DETAIL_VIEWS: DetailView[] = ['overview', 'findings', 'architecture', 'hotspots', 'dependencies', 'history', 'scans']
export function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (!segments.length || segments[0] === 'dashboard') return { page: 'dashboard' }
if (segments[0] === 'activity') return { page: 'activity' }
if (segments[0] === 'queue') return { page: 'queue' }
if (segments[0] === 'settings') return { page: 'settings' }
if (segments[0] === 'repositories' && segments[1]) {
let repositoryId = segments[1]
try {
repositoryId = decodeURIComponent(repositoryId)
} catch {
// Keep the raw segment in the not-found state if a malformed URL is supplied.
}
const view = DETAIL_VIEWS.includes(segments[2] as DetailView) ? segments[2] as DetailView : 'overview'
return { page: 'repositories', repositoryId, view }
}
return { page: 'repositories' }
}
export function routePath(route: Route): string {
if (route.page === 'dashboard') return '/'
if (route.page === 'activity') return '/activity'
if (route.page === 'queue') return '/queue'
if (route.page === 'settings') return '/settings'
if (route.repositoryId) return `/repositories/${encodeURIComponent(route.repositoryId)}/${route.view ?? 'overview'}`
return '/repositories'
}
+636
View File
@@ -0,0 +1,636 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e8eff8;
background: #070d16;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: #070d16; }
body { min-width: 320px; min-height: 100vh; margin: 0; background: radial-gradient(circle at 12% -10%, rgba(42, 76, 112, .24), transparent 34%), #070d16; }
body, button, input, select { font: inherit; }
button, a, input, select { -webkit-tap-highlight-color: transparent; }
a { color: #9fc4e7; text-decoration: none; }
a:hover { color: #d9ecff; }
button { border: 1px solid #315272; border-radius: 8px; padding: 9px 13px; color: #ecf5ff; background: #173553; cursor: pointer; transition: border-color .16s ease, background .16s ease, transform .16s ease; }
button:hover:not(:disabled) { border-color: #6d99c3; background: #1d4164; }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { cursor: wait; opacity: .55; }
button.ghost { background: #0c1726; }
button.text-button { border: 0; padding: 5px 0; color: #9cc2e8; background: transparent; }
button.text-button:hover:not(:disabled) { color: #e3f1ff; background: transparent; }
input, select { width: 100%; min-height: 38px; border: 1px solid #294661; border-radius: 8px; padding: 8px 10px; color: #eef6ff; background: #0b1625; outline: none; }
input::placeholder { color: #627891; }
input:focus, select:focus, button:focus-visible, a:focus-visible, summary:focus-visible { border-color: #80b2df; outline: 3px solid rgba(91, 157, 210, .23); outline-offset: 1px; }
code { color: #b8d7f1; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: .88em; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { color: #eff6ff; }
h1 { margin-bottom: 8px; font-size: clamp(28px, 3vw, 38px); letter-spacing: -.035em; line-height: 1.05; }
h2 { margin-bottom: 8px; font-size: clamp(20px, 2vw, 27px); letter-spacing: -.025em; }
h3 { margin-bottom: 7px; font-size: 17px; letter-spacing: -.015em; }
.muted { max-width: 72ch; margin-bottom: 0; color: #8da2b9; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.eyebrow { color: #82a5c8; font-size: 10px; font-weight: 650; letter-spacing: .13em; text-transform: uppercase; }
kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; border: 1px solid #35506d; border-bottom-color: #213a53; border-radius: 5px; padding: 2px 5px; color: #9fb8d1; background: #0a1523; font-family: inherit; font-size: 10px; line-height: 1.2; }
.app-shell { min-height: 100vh; }
.app-header { position: sticky; z-index: 20; top: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 26px; width: min(100% - 48px, 1600px); min-height: 68px; margin: 0 auto; border-bottom: 1px solid rgba(49, 76, 104, .56); background: rgba(7, 13, 22, .9); backdrop-filter: blur(18px); }
.brand-lockup { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.brand-lockup > a { display: flex; align-items: center; gap: 9px; color: #f1f7ff; }
.brand-lockup > a > span:last-child { display: grid; gap: 1px; }
.brand-lockup strong { font-size: 16px; letter-spacing: -.02em; }
.brand-lockup small { color: #718aa4; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
.brand-mark { display: grid; width: 29px; height: 29px; place-items: center; border: 1px solid #4d78a1; border-radius: 8px; color: #d9edff; background: linear-gradient(145deg, #1c4b72, #11243a); font-size: 10px; font-weight: 800; letter-spacing: .04em; }
.version-badge { border: 1px solid #294861; border-radius: 99px; padding: 4px 7px; color: #87abc9; background: #0c1b2c; font-size: 9px; font-weight: 700; letter-spacing: .07em; }
.global-nav { display: flex; align-items: center; align-self: stretch; gap: 4px; min-width: 0; }
.global-nav a { display: inline-flex; align-items: center; gap: 7px; align-self: stretch; border-bottom: 2px solid transparent; padding: 0 11px; color: #8ba1b8; font-size: 12px; white-space: nowrap; }
.global-nav a span { color: #6f8aa6; font-size: 10px; }
.global-nav a:hover { color: #e4f2ff; }
.global-nav a.active { border-bottom-color: #62b2d0; color: #f2f7fd; background: linear-gradient(180deg, transparent, rgba(55, 112, 145, .11)); }
.header-actions { display: flex; align-items: center; gap: 7px; }
.shortcut-button { display: inline-flex; align-items: center; gap: 12px; border-color: #294560; padding: 7px 8px 7px 11px; color: #a8bed5; background: #0b1625; font-size: 11px; }
.shortcut-button kbd { margin-left: 2px; }
.icon-button { display: grid; width: 35px; height: 35px; place-items: center; padding: 0; border-color: #294560; color: #a5c2dc; background: #0b1625; font-size: 18px; line-height: 1; }
.sync-icon { font-size: 21px; }
.page-content { width: min(100% - 48px, 1600px); margin: 0 auto; padding: 29px 0 60px; }
.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 28px; margin-bottom: 22px; }
.page-heading h1 { display: flex; align-items: baseline; gap: 10px; }
.page-heading-actions { display: flex; flex-wrap: wrap; justify-content: end; gap: 8px; }
.count-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 24px; border: 1px solid #31516d; border-radius: 6px; color: #9dc0df; background: #0e2238; font-size: 12px; letter-spacing: 0; }
.global-banner { width: min(100% - 48px, 1600px); margin: 17px auto -3px; }
.error-banner, .notice-banner { border-radius: 8px; padding: 10px 12px; font-size: 12px; line-height: 1.45; }
.error-banner { border: 1px solid #713a49; color: #ffdce2; background: #2d141d; }
.notice-banner { border: 1px solid #2d625a; color: #c8f0e1; background: #0e2927; }
.positive { color: #77e0a6 !important; }
.negative { color: #ff9ba8 !important; }
.section-card { min-width: 0; border: 1px solid #21384f; border-radius: 11px; padding: 18px; background: rgba(11, 22, 37, .94); box-shadow: 0 14px 42px rgba(0, 0, 0, .12); }
.catalog-toolbar { display: grid; gap: 14px; margin-bottom: 14px; }
.search-control, .switcher-search { display: flex; align-items: center; gap: 8px; min-width: 0; border: 1px solid #294863; border-radius: 8px; padding: 0 8px 0 11px; background: #091522; }
.search-control:focus-within, .switcher-search:focus-within { border-color: #6f9fc8; box-shadow: 0 0 0 3px rgba(83, 142, 190, .12); }
.search-control input, .switcher-search input { min-height: 42px; border: 0; padding-left: 0; background: transparent; box-shadow: none; }
.search-glyph { color: #78a0c1; font-size: 20px; line-height: 1; }
.search-control kbd { flex: 0 0 auto; }
.filter-grid { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: 9px; }
.filter-grid label, .pagination-controls label { display: grid; gap: 6px; color: #7791ab; font-size: 10px; }
.filter-grid select { min-height: 35px; padding: 6px 8px; font-size: 12px; }
.toolbar-footer, .pagination-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #7992ac; font-size: 11px; }
.filter-result { color: #9bb2c8; }
.catalog-table-card { padding: 0; overflow: hidden; }
.table-scroll { overflow-x: auto; }
.repository-table { width: 100%; min-width: 890px; border-collapse: collapse; text-align: left; }
.repository-table th { border-bottom: 1px solid #254159; padding: 13px 16px; color: #7590aa; font-size: 10px; font-weight: 650; letter-spacing: .09em; text-transform: uppercase; white-space: nowrap; }
.repository-table td { border-bottom: 1px solid rgba(35, 58, 80, .7); padding: 13px 16px; color: #b3c5d8; font-size: 12px; vertical-align: middle; }
.repository-table tbody tr:last-child td { border-bottom: 0; }
.repository-table tbody tr:hover { background: rgba(26, 57, 87, .22); }
.sort-button { display: inline-flex; align-items: center; gap: 7px; border: 0; padding: 0; color: inherit; background: transparent; font-size: inherit; font-weight: inherit; letter-spacing: inherit; text-transform: inherit; }
.sort-button:hover:not(:disabled) { border: 0; color: #d8eafb; background: transparent; }
.sort-button span { color: #83b6d4; font-size: 13px; }
.repo-cell { display: flex; align-items: center; gap: 10px; min-width: 235px; }
.repo-cell > div:last-child { min-width: 0; }
.repo-link { display: block; max-width: 330px; overflow: hidden; border: 0; padding: 0; color: #e8f2fc; background: transparent; font-size: 13px; font-weight: 650; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.repo-link:hover:not(:disabled) { border: 0; color: #9cdbf5; background: transparent; }
.repo-cell small, .table-date small { display: block; margin-top: 4px; color: #718aa4; font-size: 10px; }
.star-button { display: grid; flex: 0 0 auto; width: 27px; height: 27px; place-items: center; border: 0; padding: 0; color: #5f7893; background: transparent; font-size: 18px; line-height: 1; }
.star-button:hover:not(:disabled) { border: 0; color: #f0ce7b; background: transparent; }
.star-button.pinned { color: #f0c86f; }
.status-label, .readiness-label { display: inline-flex; align-items: center; gap: 7px; color: #9bafc4; font-size: 11px; white-space: nowrap; }
.status-label i, .readiness-label i, .activity-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #71859b; box-shadow: 0 0 0 3px rgba(113, 133, 155, .08); }
.status-current i, .status-label.status-current i { background: #64d3a0; box-shadow: 0 0 0 3px rgba(100, 211, 160, .1); }
.status-scanning i, .status-queued i, .status-stale i { background: #e6b766; box-shadow: 0 0 0 3px rgba(230, 183, 102, .1); }
.status-failed i, .status-unavailable i { background: #e77f8e; box-shadow: 0 0 0 3px rgba(231, 127, 142, .1); }
.readiness-ready i { background: #64d3a0; }
.readiness-warnings i { background: #e6b766; }
.readiness-blocked i { background: #e77f8e; }
.health-value { color: #d4e8fa; font-size: 14px; }
.table-date { color: #b7cadc; white-space: nowrap; }
.table-action { padding: 6px 9px; border-color: #2c4c69; color: #a6c6e1; background: #0f2237; font-size: 11px; }
.pagination-bar { border-top: 1px solid #21384f; padding: 12px 16px; }
.pagination-controls { display: flex; align-items: center; gap: 9px; }
.pagination-controls label { display: flex; align-items: center; grid-template-columns: auto auto; gap: 6px; }
.pagination-controls select { width: auto; min-height: 30px; padding: 4px 7px; font-size: 11px; }
.pagination-controls button { padding: 6px 9px; font-size: 11px; }
.page-number { min-width: 74px; color: #a1b8ce; text-align: center; }
.loading-state { display: flex; align-items: center; gap: 10px; min-height: 130px; color: #9db6cc; font-size: 13px; }
.loading-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #68c8ca; box-shadow: 0 0 0 5px rgba(104, 200, 202, .1); animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; transform: scale(.72); } }
.empty-state { display: grid; justify-items: start; align-content: center; min-height: 260px; padding: 34px; }
.empty-state h1, .empty-state h2 { max-width: 620px; }
.empty-state .muted { margin-bottom: 17px; }
.empty-icon { display: grid; width: 36px; height: 36px; place-items: center; margin-bottom: 15px; border: 1px solid #345474; border-radius: 9px; color: #91c2df; background: #102b42; font-size: 20px; }
.button-link { display: inline-flex; align-items: center; min-height: 38px; border: 1px solid #315272; border-radius: 8px; padding: 8px 13px; color: #ecf5ff; background: #173553; }
.button-link:hover { border-color: #6d99c3; color: #fff; background: #1d4164; }
.compact-empty { min-height: 180px; padding: 22px 0; }
.breadcrumb-row { display: flex; align-items: center; gap: 9px; margin-bottom: 11px; color: #6f89a2; font-size: 11px; }
.breadcrumb-row button { border: 0; padding: 0; color: #b8cce0; background: transparent; font-size: 11px; }
.breadcrumb-row button:hover:not(:disabled) { border: 0; color: #eff7ff; background: transparent; }
.detail-header { display: grid; grid-template-columns: minmax(300px, 1.35fr) minmax(270px, 1fr) auto auto; align-items: center; gap: 19px; min-height: 112px; padding: 16px 18px; }
.detail-identity { display: flex; align-items: center; min-width: 0; gap: 12px; }
.detail-identity > div:last-child { min-width: 0; }
.provider-mark { display: grid; flex: 0 0 27px; width: 27px; height: 27px; place-items: center; border: 1px solid #36597e; border-radius: 7px; color: #b8d7f2; background: #10243b; font-size: 11px; font-weight: 750; }
.provider-mark.large { width: 36px; height: 36px; border-radius: 9px; font-size: 13px; }
.detail-header h1 { margin: 3px 0 4px; overflow: hidden; font-size: clamp(20px, 2.2vw, 29px); text-overflow: ellipsis; white-space: nowrap; }
.detail-description { max-width: 52ch; margin: 0; overflow: hidden; color: #8098b2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.detail-meta { display: flex; flex-wrap: wrap; gap: 10px 16px; }
.detail-meta span { display: grid; gap: 4px; color: #c2d3e2; font-size: 11px; }
.detail-meta strong { color: #718ca7; font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
.detail-status { display: grid; justify-items: end; gap: 3px; min-width: 102px; }
.detail-status > strong { color: #ebf5fd; font-size: 18px; letter-spacing: -.02em; }
.detail-status small { max-width: 130px; overflow: hidden; color: #7f96ad; font-size: 9px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.detail-actions { display: flex; align-items: center; justify-content: end; gap: 6px; }
.detail-actions > button:first-child { min-width: 77px; }
.action-menu { position: relative; }
.action-menu summary { list-style: none; border: 1px solid #315272; border-radius: 8px; padding: 9px 11px; color: #d7e8f8; background: #0e2339; cursor: pointer; font-size: 12px; }
.action-menu summary::-webkit-details-marker { display: none; }
.action-menu summary::after { display: inline-block; margin-left: 7px; color: #7398b8; content: '⌄'; }
.action-menu[open] summary { border-color: #79a6cf; }
.menu-popover { position: absolute; z-index: 4; top: calc(100% + 7px); right: 0; display: grid; min-width: 155px; padding: 5px; border: 1px solid #31516c; border-radius: 8px; background: #0c1b2b; box-shadow: 0 15px 34px rgba(0, 0, 0, .35); }
.menu-popover button { border: 0; border-radius: 5px; padding: 8px 9px; color: #b9cde0; background: transparent; text-align: left; font-size: 11px; }
.menu-popover button:hover:not(:disabled) { border: 0; color: #eff7ff; background: #173653; }
.header-star { width: 32px; height: 35px; border: 1px solid #294560; border-radius: 8px; background: #0b1625; }
.header-star:hover:not(:disabled) { border: 1px solid #6d99c3; background: #173653; }
.detail-tabs { display: flex; gap: 3px; margin: 14px 0; border-bottom: 1px solid #20384f; overflow-x: auto; }
.detail-tabs a { position: relative; display: inline-flex; align-items: center; min-height: 38px; border-bottom: 2px solid transparent; padding: 0 12px; color: #7f96ad; font-size: 12px; white-space: nowrap; }
.detail-tabs a:hover { color: #dcecfb; }
.detail-tabs a.active { border-bottom-color: #6ab9d0; color: #eff7ff; }
.advanced-info { display: grid; grid-template-columns: 220px minmax(0, 1fr); align-items: center; gap: 25px; margin-bottom: 14px; }
.advanced-info h2 { margin: 5px 0 0; font-size: 19px; }
.info-grid { display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)); gap: 13px 20px; }
.info-grid span, .settings-list div { display: grid; gap: 4px; color: #bed0e0; font-size: 11px; }
.info-grid strong, .settings-list dt { color: #7089a3; font-size: 9px; font-weight: 650; letter-spacing: .07em; text-transform: uppercase; }
.detail-content { min-width: 0; }
.job-progress { display: grid; gap: 9px; margin-top: 12px; border: 1px solid #315875; border-radius: 9px; padding: 11px 14px; background: #0b1d2d; }
.job-progress > div:first-child { display: flex; justify-content: space-between; gap: 12px; color: #cde0f1; font-size: 12px; }
.job-progress > div:first-child span { color: #85a0ba; text-transform: capitalize; }
.progress-track { height: 5px; overflow: hidden; border-radius: 99px; background: #1b3147; }
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #64c6d1, #78daa2); transition: width .25s ease; }
.repository-facts { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0; }
.repository-facts span { border: 1px solid #284a67; border-radius: 6px; padding: 7px 9px; color: #b2c9df; background: #0b1c2d; font-size: 11px; }
.repository-empty { min-height: 330px; justify-items: start; }
.view-stack { display: grid; gap: 14px; min-width: 0; }
.low-signal-notice { margin: 0; }
.metric-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.metric-grid article { min-width: 0; border: 1px solid #203a53; border-radius: 9px; padding: 13px; background: #0a1727; }
.metric-grid.low-signal article { border-color: transparent; padding: 9px 10px; background: transparent; }
.metric-grid span { display: block; min-height: 27px; color: #87a0b8; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; }
.metric-grid strong { display: block; margin-top: 7px; color: #e9f4fe; font-size: 24px; letter-spacing: -.03em; }
.metric-grid.low-signal strong { color: #a5b9cb; font-size: 21px; }
.metric-grid small { display: block; min-height: 25px; margin-top: 4px; color: #718aa4; font-size: 9px; line-height: 1.35; }
.insight-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
.insight-card { min-width: 0; border-left: 3px solid #3d6688; border-top: 1px solid #213b55; border-right: 1px solid #213b55; border-bottom: 1px solid #213b55; border-radius: 8px; padding: 14px 15px; background: #0b1929; }
.insight-card.insight-ready { border-left-color: #65d3a0; }
.insight-card.insight-warnings { border-left-color: #e6b766; }
.insight-card.insight-blocked { border-left-color: #e77f8e; }
.insight-card strong { display: block; margin: 6px 0 4px; overflow: hidden; color: #e3f1fc; font-size: 14px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.insight-card p { margin: 0; overflow: hidden; color: #8199b1; font-size: 11px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
.overview-columns { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); gap: 14px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 15px; }
.section-heading h3 { margin: 5px 0 0; }
.section-note { max-width: 39ch; color: #7890a9; font-size: 10px; line-height: 1.45; text-align: right; }
.delta { border-radius: 5px; padding: 5px 8px; color: #a9bfd3; background: #0a1827; font-size: 10px; white-space: nowrap; }
.score-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.score-card { min-width: 0; border: 1px solid #203a53; border-radius: 8px; padding: 12px; background: #0a1726; }
.score-card > div:first-child, .language > div:first-child { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.score-card span, .language span { color: #a0b4c9; font-size: 11px; }
.score-card strong { color: #e4f0fb; font-size: 20px; }
.score-card small { display: block; margin-top: 7px; color: #859cb4; font-size: 10px; }
.bar { height: 5px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: #1b2b3e; }
.bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #5686b5, #7abfd0); }
.release-gate-card { align-content: start; }
.gate-list { display: grid; gap: 7px; margin-top: 15px; }
.gate-check { display: grid; grid-template-columns: 50px minmax(0, 1fr); gap: 9px; align-items: start; border: 1px solid #20384f; border-radius: 7px; padding: 9px; background: #091522; }
.gate-state { display: inline-flex; justify-content: center; border: 1px solid currentColor; border-radius: 5px; padding: 4px 5px; font-size: 9px; letter-spacing: .06em; text-transform: uppercase; }
.gate-pass { color: #77dca3; }
.gate-warn { color: #ecc16e; }
.gate-block { color: #ff929f; }
.gate-check strong { display: block; color: #dceaf7; font-size: 11px; }
.gate-check small { display: block; margin-top: 4px; color: #7d94ad; font-size: 10px; line-height: 1.35; }
.signals-columns { grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); }
.language-list { display: grid; gap: 12px; margin-top: 17px; }
.language strong { color: #dce7f5; font-size: 11px; }
.signal-stack { display: grid; gap: 14px; }
.signal-card h3 { margin-bottom: 5px; }
.compact-dl { display: grid; gap: 8px; margin: 15px 0 0; }
.compact-dl div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(44, 63, 86, .44); padding-bottom: 6px; }
.compact-dl dt { color: #8298b2; font-size: 11px; }
.compact-dl dd { margin: 0; color: #d9e7f4; font-size: 11px; text-align: right; }
.empty-inline { padding: 16px 0 6px; color: #8095ac; font-size: 11px; line-height: 1.45; }
.finding-filters { display: grid; grid-template-columns: minmax(220px, 1fr) 150px 180px; gap: 8px; margin-bottom: 13px; }
.finding-filters input, .finding-filters select { width: 100%; }
.findings-list, .hotspot-list, .scan-run-list { display: grid; gap: 7px; }
.finding { display: block; overflow: hidden; border: 1px solid #20384f; border-radius: 8px; background: #091522; }
.finding-summary { display: grid; grid-template-columns: 69px minmax(0, 1fr) 42px 14px; align-items: start; gap: 11px; padding: 11px; cursor: pointer; list-style: none; }
.finding-summary::-webkit-details-marker { display: none; }
.finding-summary::after { align-self: center; color: #7f9ab4; content: '+'; font-size: 15px; line-height: 1; text-align: right; }
.finding[open] .finding-summary::after { content: '−'; }
.finding-copy { min-width: 0; }
.finding-copy strong, .finding-copy > span { display: block; }
.finding strong { color: #e3effa; font-size: 12px; font-weight: 600; line-height: 1.35; }
.finding-copy > span { margin-top: 4px; color: #7f94ae; font-size: 10px; overflow-wrap: anywhere; }
.severity { display: inline-flex; justify-content: center; border: 1px solid currentColor; border-radius: 5px; padding: 4px 5px; font-size: 9px; letter-spacing: .05em; text-transform: uppercase; }
.severity-blocker { color: #ff909b; }
.severity-warning { color: #efc16e; }
.severity-info { color: #91acc7; }
.deduction { justify-self: end; color: #90a5be; font-size: 10px; }
.finding-details { border-top: 1px solid #20384f; padding: 12px 13px 13px 93px; background: #0b1a2a; }
.finding-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px 18px; margin: 0; }
.finding-detail-grid div { display: grid; min-width: 0; gap: 4px; }
.finding-detail-grid dt { color: #718ba5; font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
.finding-detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #c4d5e4; font-size: 10px; line-height: 1.35; }
.finding.suppressed { opacity: .64; }
.finding.suppressed .severity { color: #b8a36b; border-color: #66552d; background: #2b2412; }
.hotspot-row { display: grid; grid-template-columns: 37px minmax(0, 1fr) 100px; gap: 12px; align-items: center; border: 1px solid #20384f; border-radius: 8px; padding: 11px; background: #091522; }
.hotspot-rank { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 7px; color: #9bb5ce; background: #13283e; font-size: 12px; font-weight: 700; }
.hotspot-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.hotspot-title strong { overflow-wrap: anywhere; color: #e1edf8; font-size: 12px; }
.hotspot-title span { color: #8197b0; font-size: 10px; }
.hotspot-reasons { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.hotspot-reasons span { border-radius: 5px; padding: 3px 5px; color: #8da3bc; background: #102137; font-size: 9px; }
.hotspot-bar i { background: linear-gradient(90deg, #628fc0, #c4766f); }
.hotspot-metrics { display: grid; justify-items: end; gap: 2px; }
.hotspot-metrics strong { color: #e2effa; font-size: 23px; }
.hotspot-metrics small { color: #7f95ae; font-size: 9px; }
.architecture-summary { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
.architecture-stats { display: grid; grid-template-columns: repeat(3, 85px); gap: 7px; }
.architecture-stats div { border: 1px solid #203b58; border-radius: 8px; padding: 10px; background: #091624; }
.architecture-stats strong { display: block; color: #e8f4fe; font-size: 21px; }
.architecture-stats strong small { color: #6f89a6; font-size: 11px; font-weight: 500; }
.architecture-stats span { color: #8196af; font-size: 9px; line-height: 1.2; text-transform: uppercase; }
.architecture-canvas { height: 650px; padding: 0; overflow: hidden; }
.react-flow__node { min-width: 150px; border: 1px solid #36587e !important; border-radius: 8px !important; padding: 10px 12px !important; color: #edf6ff !important; background: #10233a !important; box-shadow: 0 10px 28px rgba(0, 0, 0, .25); font-size: 11px; }
.react-flow__node.arch-hot { border-color: #a24b59 !important; }
.react-flow__node.arch-warm { border-color: #9b7634 !important; }
.react-flow__node.arch-calm { border-color: #37638a !important; }
.react-flow__edge-path { stroke: #668ab3; stroke-width: 1.7; }
.react-flow__controls { overflow: hidden; border: 1px solid #274563; border-radius: 8px; }
.react-flow__controls button { border-radius: 0; color: #fff; background: #10233a; }
.react-flow__minimap { overflow: hidden; border: 1px solid #274563; border-radius: 8px; background: #08131f !important; }
.trend-chart { display: flex; align-items: end; gap: 7px; min-height: 250px; overflow-x: auto; padding: 18px 6px 1px; }
.trend-column { display: grid; flex: 1 0 40px; grid-template-rows: 19px 180px 30px; gap: 5px; min-width: 40px; max-width: 70px; text-align: center; }
.trend-column > span { color: #9fb2c8; font-size: 10px; }
.trend-track { display: flex; align-items: end; overflow: hidden; border-radius: 6px; background: #0a1726; }
.trend-track i { display: block; width: 100%; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, #6d9bc8, #365d84); }
.trend-column small { color: #748aa4; font-size: 9px; line-height: 1.2; }
.comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.delta-grid { display: grid; gap: 7px; margin-top: 14px; }
.delta-grid div { display: flex; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(44, 63, 86, .45); padding-bottom: 6px; }
.delta-grid span { color: #8499b2; font-size: 11px; }
.delta-grid strong { font-size: 11px; }
.delta-findings { display: grid; gap: 7px; margin-top: 14px; }
.delta-findings > div { border-radius: 6px; padding: 8px 9px; font-size: 10px; line-height: 1.4; }
.delta-findings .resolved { color: #a5dac3; background: #102922; }
.delta-findings .added { color: #efacb4; background: #2d171c; }
.scan-run-card { margin-top: 0; }
.scan-run-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border-color: #20384f; border-radius: 7px; padding: 10px 11px; color: #d8e7f3; background: #091522; text-align: left; }
.scan-run-row:hover:not(:disabled), .scan-run-row.selected { border-color: #5684ad; background: #122a42; }
.scan-run-row span { min-width: 0; }
.scan-run-row span strong, .scan-run-row span small { display: block; }
.scan-run-row span strong { font-size: 12px; }
.scan-run-row span small { margin-top: 3px; color: #7e94ab; font-size: 10px; }
.scan-run-row > strong { font-size: 18px; }
.summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-bottom: 14px; }
.summary-strip > div { border: 1px solid #20384f; border-radius: 8px; padding: 13px; background: #0a1726; }
.summary-strip span { display: block; color: #7c95ad; font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
.summary-strip strong { display: block; margin-top: 6px; color: #e5f1fb; font-size: 21px; letter-spacing: -.02em; }
.activity-card, .queue-card { min-height: 280px; }
.activity-list, .queue-list { display: grid; gap: 5px; }
.activity-row, .queue-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto auto; align-items: center; gap: 11px; width: 100%; border-color: #20384f; border-radius: 7px; padding: 11px 12px; background: #091522; text-align: left; }
.activity-row:hover:not(:disabled) { border-color: #5684ad; background: #122a42; }
.activity-row > span:nth-child(2) { min-width: 0; }
.activity-row strong, .queue-row strong { display: block; overflow: hidden; color: #dbeaf6; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.activity-row small, .queue-row small { display: block; margin-top: 3px; color: #7e94ab; font-size: 10px; }
.activity-health { color: #d9eafb !important; font-size: 15px !important; }
.activity-dot { width: 7px; height: 7px; }
.row-chevron { color: #7190ac; font-size: 16px; }
.queue-row { grid-template-columns: 110px minmax(0, 1fr) auto; }
.queue-row > span:nth-child(2) { min-width: 0; }
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.settings-grid h2, .settings-note h2 { margin-top: 6px; font-size: 20px; }
.settings-list { display: grid; gap: 11px; margin: 22px 0; }
.settings-list div { display: flex; align-items: baseline; justify-content: space-between; gap: 15px; border-bottom: 1px solid rgba(44, 63, 86, .45); padding-bottom: 8px; }
.settings-list dd { margin: 0; color: #d3e2ef; font-size: 11px; text-align: right; }
.settings-list .status-label { font-size: 11px; }
.local-form { display: flex; align-items: end; gap: 8px; margin-top: 20px; }
.local-form label { display: grid; flex: 1; gap: 6px; color: #91a8c0; font-size: 10px; }
.local-form button { white-space: nowrap; }
.local-result { display: grid; gap: 4px; margin-top: 17px; border-left: 3px solid #65d3a0; border-radius: 6px; padding: 10px 11px; color: #a9c0d5; background: #0b2022; font-size: 11px; }
.local-result strong { color: #e4f3fb; }
.settings-note { margin-top: 14px; }
.settings-counts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 17px; }
.settings-counts span { border: 1px solid #294861; border-radius: 6px; padding: 7px 9px; color: #91aac2; font-size: 11px; }
.settings-counts strong { color: #dff0fc; }
.modal-backdrop { position: fixed; z-index: 40; inset: 0; display: grid; place-items: start center; padding: min(17vh, 150px) 20px 20px; background: rgba(2, 6, 11, .72); backdrop-filter: blur(5px); }
.switcher-modal { width: min(620px, 100%); overflow: hidden; border: 1px solid #3c607d; border-radius: 11px; background: #0b1828; box-shadow: 0 25px 80px rgba(0, 0, 0, .5); }
.switcher-heading { display: flex; align-items: start; justify-content: space-between; gap: 15px; padding: 18px 18px 13px; }
.switcher-heading h2 { margin: 5px 0 0; font-size: 22px; }
.switcher-search { margin: 0 15px 11px; }
.switcher-results { display: grid; max-height: min(54vh, 440px); overflow-y: auto; padding: 0 7px 7px; }
.switcher-item { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; border: 1px solid transparent; border-radius: 7px; padding: 10px 9px; color: #dceaf6; background: transparent; text-align: left; }
.switcher-item:hover:not(:disabled), .switcher-item.active { border-color: #365e7f; background: #15314b; }
.switcher-item > span:nth-child(2) { min-width: 0; }
.switcher-item strong, .switcher-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.switcher-item strong { font-size: 12px; }
.switcher-item small { margin-top: 3px; color: #839ab2; font-size: 10px; }
.switcher-star { color: #607990; font-size: 16px; text-align: center; }
.switcher-star.pinned { color: #f0c86f; }
.switcher-footer { display: flex; flex-wrap: wrap; gap: 15px; border-top: 1px solid #20384f; padding: 11px 17px; color: #7892ab; font-size: 10px; }
.switcher-footer span { display: inline-flex; align-items: center; gap: 4px; }
.sr-only { position: absolute; left: -9999px; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 1250px) {
.app-header { grid-template-columns: auto 1fr; gap: 17px; }
.global-nav { grid-column: 1 / -1; grid-row: 2; min-height: 38px; border-top: 1px solid rgba(49, 76, 104, .42); overflow-x: auto; }
.global-nav a { min-height: 38px; }
.header-actions { grid-column: 2; grid-row: 1; justify-content: end; }
.app-header { padding-top: 4px; }
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.detail-header { grid-template-columns: minmax(250px, 1.4fr) minmax(250px, 1fr) auto; }
.detail-actions { grid-column: 1 / -1; justify-content: start; }
.detail-status { justify-items: start; }
.detail-status small { text-align: left; }
.filter-grid { grid-template-columns: repeat(3, minmax(140px, 1fr)); }
}
@media (max-width: 900px) {
.page-heading { align-items: start; flex-direction: column; }
.page-heading-actions { justify-content: start; }
.detail-header { grid-template-columns: 1fr 1fr; }
.detail-identity { grid-column: 1 / -1; }
.detail-meta { align-content: start; }
.detail-status { align-self: start; }
.detail-actions { grid-column: 1 / -1; }
.overview-columns, .signals-columns { grid-template-columns: 1fr; }
.advanced-info { grid-template-columns: 1fr; gap: 14px; }
.info-grid { grid-template-columns: repeat(3, minmax(110px, 1fr)); }
.summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-grid { grid-template-columns: 1fr; }
}
@media (max-width: 680px) {
.app-header, .page-content, .global-banner { width: min(100% - 24px, 1600px); }
.app-header { min-height: 60px; gap: 10px; }
.brand-lockup small, .shortcut-button span { display: none; }
.brand-lockup { gap: 6px; }
.shortcut-button { padding: 7px; }
.global-nav { gap: 0; }
.global-nav a { padding: 0 9px; font-size: 11px; }
.page-content { padding-top: 22px; }
.page-heading { margin-bottom: 17px; }
.page-heading-actions { width: 100%; }
.page-heading-actions button { flex: 1; }
.section-card { padding: 14px; }
.filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.toolbar-footer, .pagination-bar { align-items: start; flex-direction: column; }
.pagination-controls { width: 100%; flex-wrap: wrap; }
.pagination-controls label { margin-right: auto; }
.repository-table th, .repository-table td { padding-right: 12px; padding-left: 12px; }
.detail-header { grid-template-columns: 1fr; gap: 13px; }
.detail-identity, .detail-actions { grid-column: auto; }
.detail-status { justify-items: start; }
.detail-status small { text-align: left; }
.detail-actions { justify-content: start; flex-wrap: wrap; }
.detail-actions > button:first-child { flex: 1; }
.info-grid { grid-template-columns: repeat(2, minmax(110px, 1fr)); }
.metric-grid, .insight-grid, .summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metric-grid article { padding: 11px; }
.metric-grid strong { font-size: 21px; }
.insight-card { padding: 12px; }
.insight-card strong { font-size: 12px; }
.score-grid, .comparison-grid { grid-template-columns: 1fr; }
.section-heading { align-items: start; flex-direction: column; gap: 7px; }
.section-note { max-width: none; text-align: left; }
.finding-filters { grid-template-columns: 1fr; }
.finding-summary { grid-template-columns: 64px minmax(0, 1fr) 14px; }
.finding-summary .deduction { display: none; }
.finding-details { padding: 11px; }
.finding-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hotspot-row { grid-template-columns: 31px minmax(0, 1fr); }
.hotspot-metrics { grid-column: 2; grid-template-columns: repeat(4, auto); align-items: baseline; justify-content: start; gap: 8px; }
.hotspot-metrics strong { font-size: 18px; }
.architecture-summary { align-items: start; flex-direction: column; }
.architecture-stats { width: 100%; grid-template-columns: repeat(3, 1fr); }
.architecture-canvas { height: 520px; }
.activity-row { grid-template-columns: 10px minmax(0, 1fr) auto; }
.row-chevron { display: none; }
.queue-row { grid-template-columns: 1fr auto; }
.queue-row > span:first-child { grid-column: 1 / -1; }
.local-form { align-items: stretch; flex-direction: column; }
.switcher-item { grid-template-columns: 22px minmax(0, 1fr); }
.switcher-item > .status-label { display: none; }
}
@media (max-width: 430px) {
.version-badge { display: none; }
.filter-grid, .metric-grid, .insight-grid, .summary-strip { grid-template-columns: 1fr; }
.page-heading-actions { flex-direction: column; }
.page-heading-actions button { flex: initial; width: 100%; }
.detail-tabs a { padding: 0 9px; font-size: 11px; }
.info-grid { grid-template-columns: 1fr; }
.detail-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-status { grid-row: auto; }
.finding-detail-grid { grid-template-columns: 1fr; }
}
/* v1.2 signal-topology system ------------------------------------------------ */
:root {
--canvas: #07111c;
--canvas-raised: #0a1624;
--surface: #0d1b2b;
--surface-strong: #122237;
--surface-hover: #172a42;
--border: rgba(132, 160, 193, .16);
--border-strong: rgba(144, 189, 218, .34);
--text: #eff7ff;
--text-muted: #91a5bb;
--text-subtle: #6e8299;
--accent: #75d1cc;
--accent-bright: #b1f0df;
--ready: #64d39b;
--warning: #e9bb71;
--blocked: #ed7188;
--radius-sm: 9px;
--radius: 14px;
--radius-lg: 20px;
--shadow: 0 20px 55px rgba(0, 0, 0, .18);
--ease: cubic-bezier(.2, .8, .2, 1);
--motion-fast: 130ms;
--motion: 210ms;
--motion-slow: 360ms;
color: var(--text);
background: var(--canvas);
}
html { background: var(--canvas); }
body {
background:
linear-gradient(rgba(118, 171, 198, .025) 1px, transparent 1px),
linear-gradient(90deg, rgba(118, 171, 198, .025) 1px, transparent 1px),
radial-gradient(ellipse 70% 40% at 50% -10%, rgba(61, 132, 150, .16), transparent 72%),
var(--canvas);
background-size: 44px 44px, 44px 44px, auto, auto;
}
button, a, input, select, summary { transition-duration: var(--motion-fast); transition-timing-function: var(--ease); }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { border-color: var(--accent); outline-color: rgba(117, 209, 204, .27); }
button { border-color: var(--border-strong); border-radius: var(--radius-sm); background: #174052; }
button:hover:not(:disabled) { border-color: var(--accent); background: #1a4a5d; }
.app-header { width: min(100% - 56px, 1500px); min-height: 72px; border-bottom-color: var(--border); background: rgba(7, 17, 28, .82); }
.brand-home { color: var(--text); }
.brand-home:hover { color: var(--accent-bright); }
.brand-lockup-copy { display: inline-flex; align-items: center; gap: 10px; }
.brand-lockup-copy > span:last-child { display: grid; gap: 1px; }
.brand-lockup-copy strong { display: block; font-size: 16px; letter-spacing: -.04em; }
.brand-lockup-copy small { color: var(--text-subtle); font-size: 9px; font-weight: 650; letter-spacing: .11em; text-transform: uppercase; }
.brand-svg { display: block; flex: 0 0 auto; color: var(--accent); filter: drop-shadow(0 4px 10px rgba(117, 209, 204, .17)); }
.global-nav { gap: 12px; }
.global-nav a { border-bottom-width: 1px; padding: 0 8px; color: var(--text-muted); }
.global-nav a.active { border-bottom-color: var(--accent); color: var(--text); background: transparent; }
.global-nav a:hover { color: var(--accent-bright); }
.page-content { width: min(100% - 56px, 1500px); padding-top: 34px; }
.eyebrow { color: var(--accent); font-weight: 720; }
.muted { color: var(--text-muted); }
.section-card { border-color: var(--border); border-radius: var(--radius); background: rgba(13, 27, 43, .86); box-shadow: var(--shadow); }
.count-badge { border-color: var(--border-strong); color: var(--accent-bright); background: rgba(117, 209, 204, .08); }
.brand-mark { display: none; }
.header-actions { gap: 8px; }
.shortcut-button, .icon-button { border-color: var(--border); background: rgba(14, 29, 45, .72); }
.shortcut-button:hover:not(:disabled), .icon-button:hover:not(:disabled) { background: var(--surface-hover); }
/* Dashboard: composed sections rather than metric-card soup. */
.dashboard-page { display: grid; gap: 18px; }
.dashboard-hero { position: relative; display: flex; align-items: end; justify-content: space-between; min-height: 190px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 30px 34px; background: linear-gradient(120deg, rgba(18, 42, 58, .94), rgba(12, 24, 39, .91)); box-shadow: var(--shadow); }
.dashboard-hero::after { position: absolute; inset: auto -10% -70% auto; width: 440px; height: 440px; border: 1px solid rgba(117, 209, 204, .16); border-radius: 50%; box-shadow: 0 0 0 50px rgba(117, 209, 204, .025), 0 0 0 100px rgba(117, 209, 204, .018); content: ''; pointer-events: none; }
.dashboard-hero > div:first-child { position: relative; z-index: 1; }
.dashboard-hero h1 { max-width: 700px; margin: 8px 0 10px; font-size: clamp(32px, 4vw, 52px); }
.dashboard-hero h1 span { color: var(--accent-bright); }
.dashboard-hero p { margin: 0; color: var(--text-muted); font-size: 14px; }
.portfolio-signal { position: relative; z-index: 1; display: grid; justify-items: end; min-width: 230px; color: var(--accent); }
.portfolio-signal svg { width: 210px; height: 100px; filter: drop-shadow(0 0 18px rgba(117, 209, 204, .18)); }
.portfolio-signal strong { margin-top: -53px; color: var(--text); font-size: 26px; letter-spacing: -.05em; }
.portfolio-signal span { color: var(--text-muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
.dashboard-health-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--border); }
.health-strip-segment { display: grid; justify-items: start; min-height: 106px; border: 0; border-radius: 0; padding: 17px 20px; color: var(--text); background: rgba(12, 26, 41, .95); text-align: left; }
.health-strip-segment:hover:not(:disabled) { transform: translateY(-1px); border: 0; background: var(--surface-hover); }
.health-strip-segment span { color: var(--text-muted); font-size: 11px; }
.health-strip-segment strong { margin: 5px 0 3px; font-size: 28px; letter-spacing: -.06em; }
.health-strip-segment small { color: var(--text-subtle); font-size: 10px; }
.health-strip-segment.ready strong { color: var(--ready); }
.health-strip-segment.warnings strong { color: var(--warning); }
.health-strip-segment.blocked strong { color: var(--blocked); }
.health-strip-segment.all strong { color: var(--accent-bright); }
.dashboard-layout { display: grid; grid-template-columns: minmax(330px, .92fr) minmax(0, 1.45fr); gap: 18px; }
.dashboard-layout.lower { grid-template-columns: minmax(0, 1.45fr) minmax(330px, .92fr); }
.dashboard-section { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; background: rgba(11, 24, 39, .76); }
.dashboard-section h2 { margin: 5px 0 0; font-size: 20px; }
.attention-list, .activity-rail { display: grid; gap: 2px; }
.attention-row, .activity-rail-item { display: grid; grid-template-columns: 45px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; border: 0; border-radius: 8px; padding: 9px 7px; color: var(--text); background: transparent; text-align: left; }
.attention-row:hover:not(:disabled), .activity-rail-item:hover:not(:disabled) { border: 0; background: var(--surface-hover); }
.attention-row strong, .activity-rail-item strong { display: block; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.attention-row small, .activity-rail-item small { display: block; margin-top: 3px; overflow: hidden; color: var(--text-subtle); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.attention-row b, .activity-rail-item b { color: var(--text); font-size: 18px; letter-spacing: -.05em; }
.repository-visual { position: relative; display: grid; width: 100%; aspect-ratio: 1.6; place-items: center; overflow: hidden; border: 1px solid rgba(155, 184, 214, .11); border-radius: 12px; color: #7999bc; background: radial-gradient(circle at 50% 50%, rgba(94, 144, 168, .14), transparent 65%), #0a1726; }
.repository-visual.compact { width: 42px; aspect-ratio: 1; border-radius: 10px; }
.repository-visual.ready { color: var(--ready); }
.repository-visual.warnings { color: var(--warning); }
.repository-visual.blocked { color: var(--blocked); }
.repository-visual svg { width: 100%; height: 100%; overflow: visible; }
.repository-visual:not(.compact):hover .visual-orbit { stroke-dashoffset: -18px; }
.visual-orbit { transition: stroke-dashoffset var(--motion-slow) var(--ease); }
.visual-node { transform-origin: center; transition: r var(--motion) var(--ease), opacity var(--motion) var(--ease); }
.repository-visual:hover .visual-node.hot { opacity: 1; }
.visual-scan-pulse { position: absolute; inset: 10%; border: 1px solid currentColor; border-radius: inherit; animation: repo-pulse 1.45s var(--ease) infinite; }
@keyframes repo-pulse { 0% { opacity: .8; transform: scale(.72); } 100% { opacity: 0; transform: scale(1.06); } }
.matrix-section { min-height: 294px; }
.portfolio-matrix { display: grid; grid-template-columns: repeat(auto-fit, minmax(28px, 1fr)); gap: 7px; min-height: 180px; align-content: center; padding: 13px 3px 9px; }
.matrix-cell { display: grid; aspect-ratio: 1; min-width: 0; place-items: center; border: 1px solid transparent; border-radius: 7px; padding: 0; color: rgba(255, 255, 255, .85); background: #395b7a; font-size: 8px; font-weight: 700; transform: scale(var(--cell-scale)); }
.matrix-cell span { overflow: hidden; max-width: 100%; text-overflow: clip; }
.matrix-cell.ready { background: rgba(100, 211, 155, .78); }
.matrix-cell.warnings { background: rgba(233, 187, 113, .78); }
.matrix-cell.blocked { background: rgba(237, 113, 136, .8); }
.matrix-cell:hover:not(:disabled), .matrix-cell:focus-visible { position: relative; z-index: 2; border-color: var(--text); background: var(--accent); color: #0a1726; transform: scale(1.17); }
.matrix-legend { display: flex; flex-wrap: wrap; gap: 12px; color: var(--text-subtle); font-size: 10px; }
.matrix-legend span { display: inline-flex; align-items: center; gap: 5px; }
.matrix-legend i { width: 7px; height: 7px; border-radius: 50%; }
.matrix-legend .ready { background: var(--ready); }.matrix-legend .warnings { background: var(--warning); }.matrix-legend .blocked { background: var(--blocked); }
.activity-rail-item { grid-template-columns: 11px minmax(0, 1fr) auto; padding-left: 0; }
.status-orb { width: 7px; height: 7px; border-radius: 50%; background: var(--text-subtle); box-shadow: 0 0 0 4px rgba(134, 153, 174, .09); }
.status-orb.ready { background: var(--ready); box-shadow: 0 0 0 4px rgba(100, 211, 155, .1); }.status-orb.warnings { background: var(--warning); }.status-orb.blocked { background: var(--blocked); }
.pinned-grid { display: grid; gap: 3px; }
.pinned-tile { display: grid; grid-template-columns: 45px minmax(0, 1fr); align-items: center; gap: 10px; border: 0; border-radius: 8px; padding: 9px 7px; background: transparent; text-align: left; }.pinned-tile:hover:not(:disabled) { border: 0; background: var(--surface-hover); }
.pinned-tile strong, .pinned-tile small { display: block; }.pinned-tile strong { font-size: 12px; }.pinned-tile small { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }
.pin-empty { display: grid; gap: 8px; padding: 8px 0; color: var(--text-subtle); font-size: 11px; }
/* Explorer uses two intentional, persistent modes. */
.catalog-toolbar { border-color: var(--border); }
.catalog-toolbar.section-card { padding: 15px; }
.toolbar-footer { min-height: 29px; }
.view-toggle { display: inline-flex; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: rgba(5, 13, 23, .46); }
.view-toggle button { border: 0; border-radius: 0; padding: 6px 10px; color: var(--text-muted); background: transparent; font-size: 10px; }.view-toggle button:hover:not(:disabled) { border: 0; background: var(--surface-hover); }.view-toggle button.active { color: #06151d; background: var(--accent); }
.repository-table { min-width: 820px; }.repository-table th { position: sticky; z-index: 2; top: 72px; border-bottom-color: var(--border); background: #0f1e30; }.repository-table td { border-bottom-color: rgba(132, 160, 193, .1); }.repository-table tbody tr { cursor: pointer; }.repository-table tbody tr:hover, .repository-table tbody tr:focus-within { background: rgba(117, 209, 204, .065); }.repository-table tbody tr:focus { outline: 2px solid rgba(117, 209, 204, .72); outline-offset: -2px; }.repo-link { color: var(--text); }.repo-link:hover:not(:disabled) { color: var(--accent-bright); }.catalog-table-card { border-color: var(--border); }.pagination-bar { border-top-color: var(--border); }
.repository-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 13px; }.repository-tile { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 10px 32px rgba(0, 0, 0, .09); }.repository-tile-main { display: grid; width: 100%; gap: 11px; border: 0; border-radius: 0; padding: 10px; color: var(--text); background: transparent; text-align: left; }.repository-tile-main:hover:not(:disabled) { border: 0; background: linear-gradient(140deg, rgba(117, 209, 204, .1), transparent 50%); }.repository-tile-main:hover .repository-visual { transform: translateY(-2px); border-color: var(--border-strong); }.repository-tile .repository-visual { transition: transform var(--motion) var(--ease), border-color var(--motion) var(--ease); }.tile-heading, .tile-meta { display: flex; align-items: center; justify-content: space-between; gap: 9px; }.tile-heading > span { min-width: 0; }.tile-heading strong, .tile-heading small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tile-heading strong { font-size: 13px; }.tile-heading small, .tile-meta > span:last-child { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }.tile-heading b { color: var(--accent-bright); font-size: 20px; letter-spacing: -.06em; }.tile-pin { position: absolute; top: 14px; right: 14px; z-index: 2; width: 28px; height: 28px; border: 0; border-radius: 7px; padding: 0; color: var(--text-muted); background: rgba(5, 14, 24, .52); }.tile-pin:hover:not(:disabled) { border: 0; color: #ffe29a; background: rgba(5, 14, 24, .82); }.tile-pin.pinned { color: #f4cc75; }
.catalog-loading, .workspace-loading { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }.catalog-loading span, .workspace-loading span { display: block; min-height: 190px; border-radius: var(--radius); background: linear-gradient(100deg, rgba(126, 155, 184, .05) 15%, rgba(126, 155, 184, .14) 50%, rgba(126, 155, 184, .05) 85%); background-size: 200% 100%; animation: skeleton 1.3s linear infinite; }@keyframes skeleton { to { background-position: -200% 0; } }
/* Workspace puts identity, health and true architecture-first visual evidence together. */
.workspace-hero { display: grid; grid-template-columns: minmax(0, 1fr) 190px auto; align-items: center; gap: 25px; min-height: 176px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 21px 24px; background: linear-gradient(120deg, rgba(14, 33, 48, .94), rgba(12, 24, 39, .95)); box-shadow: var(--shadow); }.workspace-identity { min-width: 0; }.workspace-identity > div:first-child { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; }.workspace-identity h1 { margin: 5px 0 6px; overflow: hidden; font-size: clamp(24px, 3vw, 36px); text-overflow: ellipsis; white-space: nowrap; }.workspace-identity p { max-width: 650px; margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; }.workspace-facts { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 13px; }.workspace-facts > span { border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; background: rgba(6, 17, 28, .31); font-size: 10px; }.workspace-score { display: grid; align-content: center; justify-items: end; min-width: 85px; }.workspace-score strong { color: var(--accent-bright); font-size: 42px; letter-spacing: -.09em; line-height: .85; }.workspace-score span { margin-top: 5px; color: var(--text-subtle); font-size: 9px; letter-spacing: .09em; text-transform: uppercase; }.workspace-score small { max-width: 100px; margin-top: 5px; overflow: hidden; color: var(--text-muted); font-size: 9px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }.workspace-visual { width: 180px; align-self: stretch; min-height: 132px; }.workspace-hero .detail-actions { flex-wrap: wrap; max-width: 144px; }.workspace-hero .detail-actions > button:first-child { flex: 1 0 100%; }.detail-tabs { margin-top: 16px; border-bottom-color: var(--border); }.detail-tabs a.active { border-bottom-color: var(--accent); color: var(--accent-bright); }
.dependency-hero { overflow: hidden; background: linear-gradient(120deg, rgba(17, 43, 54, .76), rgba(12, 25, 39, .82)); }.dependency-metrics { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin: 18px -18px -18px; border-top: 1px solid var(--border); background: var(--border); }.dependency-metrics div { display: grid; gap: 4px; padding: 13px 17px; background: var(--surface); }.dependency-metrics strong { color: var(--accent-bright); font-size: 21px; }.dependency-metrics span { color: var(--text-subtle); font-size: 9px; letter-spacing: .06em; text-transform: uppercase; }.manifest-list { display: flex; flex-wrap: wrap; gap: 7px; }.manifest-list code { border: 1px solid var(--border); border-radius: 7px; padding: 7px 8px; background: rgba(4, 12, 20, .35); }.dependency-lock-heading { margin-top: 24px; }.scan-runs-workspace { min-height: 270px; }.run-readiness { color: var(--text-subtle); font-size: 10px; }.run-readiness.ready { color: var(--ready); }.run-readiness.warnings { color: var(--warning); }.run-readiness.blocked { color: var(--blocked); }
/* Operational views. */
.summary-strip > div { border-color: var(--border); border-radius: var(--radius-sm); background: rgba(13, 27, 43, .74); }.summary-strip strong { color: var(--accent-bright); }.activity-timeline { padding: 20px 24px; }.timeline-event { position: relative; display: grid; grid-template-columns: 14px 44px minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; border: 0; border-radius: 8px; padding: 10px 7px; color: var(--text); background: transparent; text-align: left; }.timeline-event:hover:not(:disabled) { border: 0; background: var(--surface-hover); }.timeline-rail { width: 8px; height: 8px; border: 2px solid var(--canvas); border-radius: 50%; background: var(--text-subtle); box-shadow: 0 0 0 1px var(--border-strong); }.timeline-rail.ready { background: var(--ready); }.timeline-rail.warnings { background: var(--warning); }.timeline-rail.blocked { background: var(--blocked); }.timeline-event::before { position: absolute; top: 0; bottom: 0; left: 10px; width: 1px; background: var(--border); content: ''; }.timeline-event:first-of-type::before { top: 50%; }.timeline-event:last-of-type::before { bottom: 50%; }.timeline-event > * { position: relative; z-index: 1; }.timeline-event strong, .timeline-event small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.timeline-event strong { font-size: 12px; }.timeline-event small { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }.timeline-event b { color: var(--accent-bright); font-size: 20px; }.queue-row { grid-template-columns: 45px minmax(0, 1fr) auto; border-color: var(--border); background: transparent; cursor: pointer; }.queue-row:hover:not(:disabled) { border-color: var(--accent); background: var(--surface-hover); }.queue-empty { display: grid; grid-template-columns: 72px minmax(0, 550px); align-items: start; gap: 18px; min-height: 250px; padding: 32px 5px; }.queue-empty h2 { margin: 5px 0 7px; }.queue-empty .muted { margin-bottom: 17px; }
/* Settings sections signal separation without form-card nesting. */
.settings-sections { display: grid; gap: 13px; }.settings-section { display: grid; grid-template-columns: minmax(210px, .72fr) minmax(260px, 1fr) auto; gap: 24px; align-items: start; border: 1px solid var(--border); border-radius: var(--radius); padding: 21px; background: rgba(13, 27, 43, .78); }.settings-section h2 { margin: 5px 0 7px; }.settings-section p { margin: 0; color: var(--text-muted); font-size: 11px; line-height: 1.5; }.settings-section dl { display: grid; gap: 8px; margin: 0; }.settings-section dl > div { display: flex; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); padding: 7px 0; }.settings-section dt { color: var(--text-subtle); font-size: 10px; }.settings-section dd { margin: 0; color: var(--text); font-size: 11px; text-align: right; }.settings-section .local-form { margin: 0; grid-column: 2 / -1; }.settings-section .local-result { grid-column: 2 / -1; }.settings-section.appearance { grid-template-columns: minmax(210px, .72fr) minmax(260px, 1fr); }.settings-section.appearance dl { grid-column: 2; }
.command-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 15px 11px; }.command-row button { border-color: var(--border); padding: 6px 8px; color: var(--text-muted); background: rgba(7, 18, 29, .52); font-size: 10px; }.command-row button:hover:not(:disabled) { color: var(--accent-bright); background: var(--surface-hover); }.switcher-item { grid-template-columns: 38px minmax(0, 1fr) auto; }.switcher-item .repository-visual { width: 32px; }.switcher-modal { border-color: var(--border-strong); border-radius: var(--radius); background: #0d1b2b; }.switcher-item:hover:not(:disabled), .switcher-item.active { border-color: rgba(117, 209, 204, .36); background: rgba(117, 209, 204, .1); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
@media (max-width: 1180px) { .dashboard-layout, .dashboard-layout.lower { grid-template-columns: 1fr; }.repository-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }.workspace-hero { grid-template-columns: minmax(0, 1fr) 160px; }.workspace-hero .detail-actions { grid-column: 1 / -1; max-width: none; justify-content: flex-start; }.workspace-hero .detail-actions > button:first-child { flex: 0 0 auto; }.settings-section { grid-template-columns: minmax(210px, .7fr) minmax(240px, 1fr); }.settings-section > button { grid-column: 2; justify-self: start; } }
@media (max-width: 760px) { .app-header, .page-content, .global-banner { width: min(100% - 28px, 1500px); }.app-header { grid-template-columns: auto 1fr; }.brand-lockup-copy small { display: none; }.global-nav { grid-column: 1 / -1; grid-row: 2; margin: 0 -2px; scrollbar-width: none; -ms-overflow-style: none; }.global-nav::-webkit-scrollbar { display: none; }.header-actions { justify-content: end; }.dashboard-hero { min-height: 180px; padding: 24px; }.dashboard-hero::after, .portfolio-signal { display: none; }.dashboard-health-strip { grid-template-columns: repeat(2, 1fr); }.dashboard-layout { gap: 13px; }.repository-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.workspace-hero { grid-template-columns: 1fr; gap: 14px; }.workspace-identity > div:first-child { display: block; }.workspace-score { justify-items: start; }.workspace-score small { text-align: left; }.workspace-visual { width: 100%; max-width: 250px; min-height: 150px; }.dependency-metrics { grid-template-columns: repeat(2, 1fr); }.settings-section, .settings-section.appearance { grid-template-columns: 1fr; gap: 14px; }.settings-section dl, .settings-section > button, .settings-section .local-form, .settings-section .local-result, .settings-section.appearance dl { grid-column: auto; }.timeline-event { grid-template-columns: 14px 40px minmax(0, 1fr) auto; }.queue-empty { grid-template-columns: 1fr; gap: 10px; }.queue-empty .brand-svg { display: none; } }
@media (max-width: 470px) { .page-content { padding-top: 23px; }.dashboard-hero { padding: 20px; }.dashboard-hero h1 { font-size: 31px; }.health-strip-segment { min-height: 87px; padding: 14px; }.health-strip-segment strong { font-size: 24px; }.repository-grid { grid-template-columns: 1fr; }.portfolio-matrix { grid-template-columns: repeat(8, 1fr); }.matrix-cell { font-size: 7px; }.toolbar-footer { align-items: flex-start; flex-wrap: wrap; }.view-toggle { order: -1; }.workspace-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }.workspace-facts > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.timeline-event { grid-template-columns: 12px 35px minmax(0, 1fr); }.timeline-event b { display: none; }.dependency-metrics { grid-template-columns: 1fr 1fr; }.settings-section { padding: 17px; } }
/* v1.3 portfolio command deck ------------------------------------------------ */
.cockpit-page { gap: 15px; }
.dashboard-command { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 235px 285px; align-items: center; min-height: 198px; overflow: hidden; border: 1px solid rgba(130, 204, 202, .2); border-radius: 22px; padding: 28px 32px; background: linear-gradient(118deg, rgba(15, 42, 57, .98), rgba(10, 25, 42, .98) 52%, rgba(19, 35, 55, .94)); box-shadow: 0 28px 80px rgba(0, 0, 0, .24); }
.dashboard-command::before { position: absolute; inset: 0; background: linear-gradient(90deg, transparent 0 47%, rgba(117, 209, 204, .09) 47.1%, transparent 47.2%), repeating-linear-gradient(0deg, transparent 0 33px, rgba(133, 176, 201, .04) 34px); content: ''; pointer-events: none; }
.dashboard-command > * { position: relative; z-index: 1; }.command-copy h1 { margin: 7px 0 7px; color: var(--text); font-size: clamp(34px, 4vw, 55px); letter-spacing: -.075em; line-height: .98; }.command-copy h1 span { color: var(--accent-bright); }.command-copy h1 small { margin-left: 8px; color: var(--text-subtle); font-size: .38em; letter-spacing: -.03em; }.command-copy p { margin: 0; color: var(--text-muted); font-size: 12px; }.command-pulse { display: grid; justify-items: center; color: var(--accent); }.command-pulse svg { width: 205px; height: 104px; overflow: visible; }.pulse-field { fill: none; stroke: currentColor; stroke-opacity: .15; stroke-width: 11; }.pulse-line { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; filter: drop-shadow(0 0 10px rgba(117, 209, 204, .35)); }.command-pulse circle { fill: var(--accent-bright); filter: drop-shadow(0 0 8px var(--accent)); }.command-pulse strong { margin-top: -51px; color: var(--text); font-size: 27px; letter-spacing: -.06em; }.command-pulse span { margin-top: 4px; color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }.command-context { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: 0; overflow: hidden; border: 1px solid rgba(135, 174, 200, .17); border-radius: 12px; background: rgba(135, 174, 200, .12); }.command-context div { display: grid; gap: 5px; padding: 15px 12px; background: rgba(7, 19, 33, .36); }.command-context dt { color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.command-context dd { margin: 0; color: var(--text); font-size: 23px; font-weight: 760; letter-spacing: -.07em; }.command-context div:first-child dd { color: var(--blocked); }.command-context div:nth-child(2) dd { color: var(--warning); }.command-context div:last-child dd { color: var(--ready); }
.dashboard-readiness-rail { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--border); }.readiness-rail-segment { display: grid; justify-items: start; min-height: 92px; border: 0; border-radius: 0; padding: 14px 19px; color: var(--text); background: rgba(11, 25, 40, .91); text-align: left; }.readiness-rail-segment:hover:not(:disabled) { border: 0; background: linear-gradient(135deg, rgba(117, 209, 204, .16), rgba(19, 42, 61, .9)); }.readiness-rail-segment > span { color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }.readiness-rail-segment strong { margin: 3px 0; font-size: 27px; letter-spacing: -.07em; }.readiness-rail-segment small { color: var(--text-muted); font-size: 10px; }.readiness-rail-segment small b { margin-left: 5px; color: var(--accent); font-size: 14px; }.readiness-rail-segment.blocked strong { color: var(--blocked); }.readiness-rail-segment.warnings strong { color: var(--warning); }.readiness-rail-segment.ready strong { color: var(--ready); }.readiness-rail-segment.all strong { color: var(--accent-bright); }
.cockpit-focus-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .75fr); gap: 15px; }.dashboard-section { border-color: rgba(132, 160, 193, .18); background: linear-gradient(135deg, rgba(13, 29, 46, .9), rgba(10, 22, 37, .82)); }.attention-section { padding: 19px; }.attention-list { gap: 3px; }.attention-row { grid-template-columns: 25px 49px minmax(0, 1fr) auto; gap: 10px; min-height: 58px; padding: 7px 8px; border: 1px solid transparent; border-radius: 10px; }.attention-row:hover:not(:disabled), .attention-row:focus-visible { border-color: rgba(117, 209, 204, .36); background: rgba(117, 209, 204, .065); }.attention-rank { color: var(--text-subtle); font-size: 9px; font-weight: 750; letter-spacing: .08em; }.attention-row .repository-visual.compact { width: 46px; height: 46px; }.attention-copy { min-width: 0; }.attention-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attention-copy small { overflow: hidden; color: var(--text-muted); font-size: 10px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }.attention-copy em { display: block; margin-top: 3px; color: var(--text-subtle); font-size: 9px; font-style: normal; }.attention-row b { color: var(--blocked); font-size: 22px; }.next-move { position: relative; display: grid; align-content: start; overflow: hidden; padding: 21px; background: linear-gradient(145deg, rgba(18, 47, 59, .88), rgba(9, 22, 38, .96)); }.next-move::after { position: absolute; right: -45px; bottom: -75px; width: 190px; height: 190px; border: 1px solid rgba(117, 209, 204, .15); border-radius: 50%; box-shadow: 0 0 0 28px rgba(117, 209, 204, .025), 0 0 0 58px rgba(117, 209, 204, .018); content: ''; pointer-events: none; }.next-move > * { position: relative; z-index: 1; }.next-move h2 { margin: 6px 0 14px; overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; }.next-move-action { display: grid; grid-template-columns: 122px minmax(0, 1fr); align-items: center; gap: 13px; width: 100%; border: 1px solid rgba(117, 209, 204, .18); padding: 10px; color: var(--text); background: rgba(5, 17, 29, .38); text-align: left; }.next-move-action:hover:not(:disabled) { border-color: var(--accent); background: rgba(10, 35, 49, .76); }.next-move-action .repository-visual { min-height: 94px; }.next-move-action span { min-width: 0; }.next-move-action strong, .next-move-action small, .next-move-action b { display: block; }.next-move-action strong { color: var(--accent-bright); font-size: 24px; letter-spacing: -.07em; }.next-move-action small { margin-top: 5px; color: var(--text-muted); font-size: 10px; line-height: 1.45; }.next-move-action b { margin-top: 11px; color: var(--accent); font-size: 10px; }
.portfolio-atlas { padding: 20px; }.atlas-heading { align-items: end; }.atlas-field { position: relative; overflow: hidden; border: 1px solid rgba(132, 160, 193, .14); border-radius: 13px; background: radial-gradient(ellipse at 70% 0, rgba(77, 166, 175, .1), transparent 45%), rgba(5, 16, 27, .52); }.atlas-contours { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none; }.atlas-contours path { fill: none; stroke: rgba(117, 209, 204, .12); stroke-width: 1; }.atlas-contours path:nth-child(2) { stroke: rgba(233, 187, 113, .1); stroke-dasharray: 4 6; }.atlas-contours path:nth-child(3) { stroke: rgba(237, 113, 136, .12); }.atlas-bands { position: relative; z-index: 1; display: grid; gap: 1px; background: rgba(135, 174, 200, .1); }.atlas-band { display: grid; grid-template-columns: 180px minmax(0, 1fr); min-height: 96px; background: rgba(8, 21, 35, .84); }.atlas-band > header { display: grid; align-content: center; gap: 3px; padding: 14px 17px; border-right: 1px solid rgba(135, 174, 200, .1); background: linear-gradient(90deg, rgba(16, 34, 51, .74), transparent); }.atlas-band > header span { color: var(--text-subtle); font-size: 9px; font-weight: 720; letter-spacing: .06em; text-transform: uppercase; }.atlas-band > header strong { color: var(--text); font-size: 28px; letter-spacing: -.07em; }.atlas-band > header small { color: var(--text-muted); font-size: 9px; line-height: 1.25; }.atlas-band.blocked > header strong { color: var(--blocked); }.atlas-band.warnings > header strong { color: var(--warning); }.atlas-band.ready > header strong { color: var(--ready); }.atlas-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(57px, 1fr)); align-content: center; gap: 6px; margin: 0; padding: 10px; list-style: none; }.atlas-repository { position: relative; display: grid; align-content: space-between; width: 100%; min-width: 0; height: 58px; overflow: hidden; border: 1px solid rgba(149, 184, 207, .16); border-radius: 8px; padding: 7px; color: var(--text); background: linear-gradient(145deg, rgba(29, 55, 75, .8), rgba(13, 29, 45, .82)); text-align: left; }.atlas-repository::before { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.07), transparent 55%); content: ''; pointer-events: none; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { z-index: 2; border-color: var(--accent); background: linear-gradient(145deg, rgba(46, 96, 110, .94), rgba(13, 40, 53, .98)); box-shadow: 0 0 0 3px rgba(117, 209, 204, .12), 0 10px 20px rgba(0, 0, 0, .24); transform: translateY(-2px); }.atlas-repository-name, .atlas-repository-health { position: relative; z-index: 1; overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }.atlas-repository-name { color: rgba(244, 250, 255, .84); font-size: 9px; font-weight: 770; letter-spacing: .04em; }.atlas-repository-health { align-self: end; color: var(--text-muted); font-size: 11px; font-weight: 700; }.atlas-repository > i { position: absolute; z-index: 1; bottom: 5px; left: 7px; width: var(--signal-strength); max-width: calc(100% - 14px); height: 2px; border-radius: 2px; background: var(--accent); box-shadow: 0 0 8px rgba(117, 209, 204, .45); }.atlas-band.blocked .atlas-repository > i { background: var(--blocked); box-shadow: 0 0 8px rgba(237, 113, 136, .42); }.atlas-band.warnings .atlas-repository > i { background: var(--warning); box-shadow: 0 0 8px rgba(233, 187, 113, .38); }.atlas-band.ready .atlas-repository > i { background: var(--ready); box-shadow: 0 0 8px rgba(100, 211, 155, .38); }.atlas-repository.stale { border-style: dashed; }.atlas-repository.scanning::after { position: absolute; inset: 3px; border: 1px solid currentColor; border-radius: 5px; content: ''; animation: atlas-scan 1.35s var(--ease) infinite; }.atlas-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 11px 1px 0; color: var(--text-subtle); font-size: 9px; }.atlas-legend span { display: inline-flex; align-items: center; gap: 5px; }.atlas-legend i { width: 14px; height: 2px; border-radius: 2px; background: var(--accent); }.atlas-legend .blocked { background: var(--blocked); }.atlas-legend .stale { height: 6px; border: 1px dashed var(--text-subtle); background: transparent; }.atlas-legend .scanning { width: 7px; height: 7px; border: 1px solid var(--accent); border-radius: 50%; background: transparent; }.portfolio-atlas[data-density='dense'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 4px; }.portfolio-atlas[data-density='dense'] .atlas-repository { height: 45px; padding: 5px; }.portfolio-atlas[data-density='dense'] .atlas-repository-name { font-size: 8px; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)); gap: 3px; }.portfolio-atlas[data-density='compact'] .atlas-repository { height: 31px; padding: 4px; border-radius: 5px; }.portfolio-atlas[data-density='compact'] .atlas-reposiLine truncated
.dashboard-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(330px, .85fr); gap: 15px; }.dashboard-bottom-grid .dashboard-section { padding: 18px; }.activity-rail-item { border-radius: 8px; }.pinned-tile { grid-template-columns: 46px minmax(0, 1fr) auto; }.pinned-tile b { color: var(--accent); }.repository-visual { border-color: rgba(140, 187, 207, .15); color: #8aaac7; background: radial-gradient(ellipse at 70% 20%, rgba(93, 159, 181, .18), transparent 58%), #091827; }.repository-visual.state-ready { color: var(--ready); }.repository-visual.state-warnings { color: var(--warning); }.repository-visual.state-blocked { color: var(--blocked); }.repository-visual svg { overflow: hidden; }.visual-wash { opacity: .75; }.visual-link { transition: stroke-opacity var(--motion) var(--ease); }.visual-contour { transform-origin: center; }.repository-visual:not(.compact):hover .contour-one { animation: visual-drift 2.8s var(--ease) infinite alternate; }.repository-visual:not(.compact):hover .visual-link { stroke-opacity: .95; }.visual-node { transform-origin: center; transition: opacity var(--motion) var(--ease); }.visual-node.hot { animation: visual-node-breathe 2.7s var(--ease) infinite; }.visual-caption { position: absolute; top: 9px; left: 10px; display: flex; gap: 5px; align-items: center; }.visual-caption i { border: 1px solid currentColor; border-radius: 99px; padding: 2px 4px; color: currentColor; font-size: 7px; font-style: normal; font-weight: 760; letter-spacing: .09em; }.visual-caption b { color: rgba(238, 247, 255, .76); font-size: 8px; font-weight: 650; }.repository-visual.compact .visual-health-line, .repository-visual.compact .visual-contour { opacity: .65; }.workspace-visual { min-height: 132px; }.workspace-visual .visual-caption { top: 11px; left: 12px; }.workspace-visual .visual-caption b { font-size: 9px; }.repository-tile .repository-visual { min-height: 124px; }.repository-tile-main:hover .repository-visual { box-shadow: 0 14px 24px rgba(0, 0, 0, .18); }
@keyframes atlas-scan { 0% { opacity: .8; transform: scale(.82); } 100% { opacity: 0; transform: scale(1.06); } } @keyframes visual-drift { to { transform: translateX(4px); } } @keyframes visual-node-breathe { 50% { opacity: .7; } }
@media (max-width: 1180px) { .dashboard-command { grid-template-columns: minmax(0, 1fr) 205px; }.command-context { grid-column: 1 / -1; max-width: 420px; }.cockpit-focus-grid, .dashboard-bottom-grid { grid-template-columns: 1fr; }.next-move { min-height: 180px; }.atlas-band { grid-template-columns: 158px minmax(0, 1fr); } }
@media (max-width: 760px) { .dashboard-command { grid-template-columns: 1fr; min-height: 0; padding: 23px 20px; }.command-pulse { position: absolute; right: -16px; bottom: -23px; opacity: .32; }.command-context { max-width: none; margin-top: 18px; }.command-copy { padding-right: 40px; }.command-copy h1 { font-size: 39px; }.dashboard-readiness-rail { grid-template-columns: repeat(2, 1fr); }.readiness-rail-segment { min-height: 78px; padding: 12px; }.readiness-rail-segment strong { font-size: 23px; }.cockpit-focus-grid { gap: 12px; }.attention-section, .next-move, .portfolio-atlas, .dashboard-bottom-grid .dashboard-section { padding: 15px; }.next-move-action { grid-template-columns: 105px minmax(0, 1fr); }.atlas-band { grid-template-columns: 1fr; }.atlas-band > header { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 9px; border-right: 0; border-bottom: 1px solid rgba(135, 174, 200, .1); padding: 11px 13px; }.atlas-band > header strong { grid-row: 1 / 3; grid-column: 2; align-self: center; }.atlas-band > header small { grid-column: 1; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(49px, 1fr)); padding: 8px; }.atlas-legend { gap: 8px; }.dashboard-bottom-grid { gap: 12px; }.workspace-visual { max-width: none; } }
@media (max-width: 470px) { .command-context div { padding: 11px 9px; }.command-context dd { font-size: 20px; }.attention-row { grid-template-columns: 20px 42px minmax(0, 1fr) auto; gap: 7px; padding: 7px 3px; }.attention-row .repository-visual.compact { width: 40px; height: 40px; }.attention-copy small { white-space: normal; }.attention-copy em { display: none; }.next-move-action { grid-template-columns: 88px minmax(0, 1fr); }.next-move-action .repository-visual { min-height: 76px; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 4px; }.atlas-repository { height: 48px; padding: 5px; }.atlas-repository-name { font-size: 8px; }.atlas-legend { display: grid; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(23px, 1fr)); } }
@media (prefers-reduced-motion: reduce) { .atlas-repository, .visual-link, .visual-node, .repository-visual, .next-move-action { transition: none !important; }.atlas-repository.scanning::after, .repository-visual:not(.compact):hover .contour-one, .visual-node.hot { animation: none !important; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { transform: none; } }
@media (max-width: 760px) { .command-pulse strong, .command-pulse span { display: none; } }
/* v1.4 remediation cockpit: decision provenance, layered surfaces and quiet technical motion. */
.cockpit-page { gap: 18px; }.dashboard-command { isolation: isolate; min-height: 212px; border-color: rgba(139, 222, 215, .24); border-radius: 26px; background: radial-gradient(ellipse at 18% 4%, rgba(50, 131, 145, .25), transparent 43%), radial-gradient(ellipse at 79% 82%, rgba(105, 95, 165, .15), transparent 48%), linear-gradient(118deg, #102d40, #0b1d31 49%, #111d35); box-shadow: 0 30px 88px rgba(0, 0, 0, .31), inset 0 1px rgba(231, 255, 252, .07); }.dashboard-command::after { position: absolute; z-index: 0; top: -48px; left: 42%; width: 300px; height: 300px; border: 1px solid rgba(116, 218, 211, .11); border-radius: 50%; box-shadow: 0 0 0 34px rgba(116, 218, 211, .025), 0 0 0 78px rgba(116, 218, 211, .015); content: ''; pointer-events: none; }.command-copy { max-width: 565px; }.command-copy h1 { text-wrap: balance; }.command-trend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }.command-trend span { display: inline-flex; align-items: center; gap: 6px; border: 1px solid rgba(150, 189, 211, .16); border-radius: 999px; padding: 5px 8px; color: var(--text-muted); background: rgba(5, 17, 29, .28); font-size: 9px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }.command-trend i { width: 6px; height: 6px; border-radius: 50%; background: var(--ready); box-shadow: 0 0 0 3px rgba(100, 211, 155, .11); }.command-trend .trend-watch { background: var(--warning); box-shadow: 0 0 0 3px rgba(233, 187, 113, .11); }.command-context { backdrop-filter: blur(12px); box-shadow: inset 0 1px rgba(255, 255, 255, .035); }.command-context div { background: linear-gradient(160deg, rgba(13, 35, 52, .68), rgba(6, 17, 30, .47)); }
.dashboard-readiness-rail { gap: 6px; overflow: visible; border: 0; border-radius: 0; background: transparent; }.readiness-rail-segment { position: relative; overflow: hidden; min-height: 99px; border: 1px solid rgba(132, 160, 193, .16); border-radius: 15px; background: linear-gradient(145deg, rgba(16, 35, 52, .88), rgba(8, 20, 34, .88)); box-shadow: inset 0 1px rgba(255,255,255,.025); }.readiness-rail-segment::after { position: absolute; right: 12px; bottom: 11px; width: 30px; height: 1px; background: currentColor; content: ''; opacity: .34; transform: rotate(-38deg); transform-origin: right; }.readiness-rail-segment:hover:not(:disabled) { border-color: rgba(117, 209, 204, .42); border-radius: 15px; box-shadow: 0 13px 24px rgba(0, 0, 0, .18); transform: translateY(-2px); }.readiness-rail-segment.blocked { color: var(--blocked); }.readiness-rail-segment.warnings { color: var(--warning); }.readiness-rail-segment.ready { color: var(--ready); }.readiness-rail-segment.all { color: var(--accent); }.readiness-rail-segment > span, .readiness-rail-segment small { color: var(--text-subtle); }.readiness-rail-segment strong { color: currentColor !important; }
.cockpit-focus-grid { align-items: stretch; gap: 18px; }.dashboard-section { border-radius: 18px; box-shadow: inset 0 1px rgba(255, 255, 255, .025), 0 15px 35px rgba(0, 0, 0, .1); }.attention-section { position: relative; overflow: hidden; padding: 22px; background: linear-gradient(145deg, rgba(15, 34, 52, .94), rgba(8, 20, 33, .91)); }.attention-section::before { position: absolute; top: 0; bottom: 0; left: 0; width: 3px; background: linear-gradient(var(--blocked), rgba(237, 113, 136, 0)); content: ''; opacity: .78; }.attention-list { gap: 5px; margin-top: 7px; }.attention-row { position: relative; grid-template-columns: 26px 52px minmax(0, 1fr) auto; min-height: 66px; overflow: hidden; border-color: rgba(132, 160, 193, .07); padding: 8px 10px; background: linear-gradient(90deg, rgba(7, 18, 30, .44), transparent); }.attention-row::before { position: absolute; top: 12px; bottom: 12px; left: 0; width: 2px; border-radius: 99px; background: var(--blocked); content: ''; opacity: .55; }.attention-row:hover:not(:disabled), .attention-row:focus-visible { border-color: rgba(117, 209, 204, .34); background: linear-gradient(90deg, rgba(117, 209, 204, .1), rgba(24, 52, 65, .12)); }.attention-copy { display: grid; align-content: center; gap: 3px; }.attention-copy small { line-height: 1.35; }.attention-context { display: flex; align-items: center; gap: 7px; min-width: 0; }.attention-context i, .next-move-context i { max-width: 116px; overflow: hidden; border: 1px solid rgba(117, 209, 204, .2); border-radius: 999px; padding: 2px 5px; color: var(--accent-bright); background: rgba(117, 209, 204, .07); font-size: 8px; font-style: normal; font-weight: 720; letter-spacing: .04em; text-overflow: ellipsis; white-space: nowrap; }.attention-context em { overflow: hidden; margin: 0; color: var(--text-subtle); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.attention-score { display: grid; justify-items: end; align-content: center; gap: 3px; }.attention-score b { color: var(--blocked); font-size: 23px; }.attention-score small { color: var(--text-subtle); font-size: 8px; white-space: nowrap; }
.next-move { border-color: rgba(117, 209, 204, .25); background: radial-gradient(ellipse at 84% 8%, rgba(77, 185, 178, .22), transparent 44%), linear-gradient(150deg, rgba(18, 52, 64, .96), rgba(8, 19, 33, .98)); }.next-move::before { position: absolute; top: 0; right: 0; width: 52%; height: 1px; background: linear-gradient(90deg, transparent, rgba(117, 209, 204, .72)); content: ''; }.next-move-action { border-radius: 13px; background: linear-gradient(125deg, rgba(5, 19, 30, .6), rgba(23, 50, 62, .35)); box-shadow: inset 0 1px rgba(255,255,255,.035); }.next-move-action:hover:not(:disabled) { box-shadow: 0 17px 28px rgba(0, 0, 0, .2), inset 0 1px rgba(255,255,255,.05); }.next-move-context { display: grid; gap: 5px; margin-top: 10px; color: var(--text-muted); font-size: 9px; font-style: normal; line-height: 1.4; }.next-move-context i { width: fit-content; }
.portfolio-atlas { padding: 22px; background: linear-gradient(145deg, rgba(13, 31, 48, .92), rgba(7, 18, 30, .9)); }.atlas-field { border-radius: 19px; background: radial-gradient(ellipse at 78% 0, rgba(77, 166, 175, .17), transparent 47%), radial-gradient(ellipse at 12% 100%, rgba(105, 80, 140, .09), transparent 49%), rgba(4, 15, 26, .74); }.atlas-bands { gap: 10px; padding: 10px; background: transparent; }.atlas-band { grid-template-columns: 196px minmax(0, 1fr); min-height: 104px; overflow: hidden; border: 1px solid rgba(144, 184, 207, .12); border-radius: 14px; background: linear-gradient(90deg, rgba(13, 34, 50, .92), rgba(5, 17, 29, .62)); }.atlas-band > header { position: relative; gap: 4px; overflow: hidden; border-right: 1px solid rgba(135, 174, 200, .1); padding: 16px 17px 15px 21px; background: linear-gradient(100deg, rgba(20, 49, 66, .92), rgba(12, 29, 43, .22)); }.atlas-lane-beacon { position: absolute; top: 16px; left: 10px; width: 3px; height: 32px; border-radius: 99px; background: var(--accent); box-shadow: 0 0 17px currentColor; }.atlas-band.blocked .atlas-lane-beacon { color: var(--blocked); background: var(--blocked); }.atlas-band.warnings .atlas-lane-beacon { color: var(--warning); background: var(--warning); }.atlas-band.ready .atlas-lane-beacon { color: var(--ready); background: var(--ready); }.atlas-band > header strong { font-size: 31px; }.atlas-river { position: relative; min-width: 0; overflow: hidden; }.atlas-river::before { position: absolute; top: 50%; right: 0; left: 0; height: 1px; background: linear-gradient(90deg, rgba(117, 209, 204, .04), rgba(117, 209, 204, .25), rgba(117, 209, 204, .03)); content: ''; }.atlas-tiles { position: relative; grid-template-columns: repeat(auto-fill, minmax(61px, 1fr)); gap: 8px; padding: 13px 14px; }.atlas-repository { height: 62px; border-radius: 12px; background: linear-gradient(150deg, rgba(35, 69, 87, .86), rgba(10, 26, 42, .92)); box-shadow: inset 0 1px rgba(255,255,255,.045), 0 7px 12px rgba(0,0,0,.12); transform: translateY(calc((var(--signal-phase) - 3) * 1px)); transition: transform var(--motion) var(--ease), border-color var(--motion) var(--ease), background var(--motion) var(--ease), box-shadow var(--motion) var(--ease); }.atlas-repository::before { background: linear-gradient(135deg, rgba(255,255,255,.11), transparent 46%); }.atlas-repository-orbit { position: absolute; top: 8px; right: 7px; width: 15px; height: 15px; border: 1px solid currentColor; border-radius: 50%; opacity: .18; }.atlas-repository-orbit::after { position: absolute; top: 2px; right: -2px; width: 3px; height: 3px; border-radius: 50%; background: currentColor; content: ''; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { border-color: currentColor; box-shadow: 0 0 0 3px rgba(117, 209, 204, .11), 0 16px 24px rgba(0, 0, 0, .28); transform: translateY(-4px) scale(1.035); }.atlas-repository-name { padding-right: 12px; font-size: 9px; }.atlas-repository > i { height: 3px; }.portfolio-atlas[data-density='dense'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(47px, 1fr)); gap: 5px; padding: 10px; }.portfolio-atlas[data-density='dense'] .atlas-repository { height: 49px; border-radius: 9px; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(30px, 1fr)); gap: 3px; padding: 8px; }.portfolio-atlas[data-density='compact'] .atlas-repository { height: 33px; border-radius: 6px; transform: none; }.portfolio-atlas[data-density='compact'] .atlas-repository-orbit { display: none; }
.repository-visual { border-radius: 14px; background: radial-gradient(ellipse at 78% 18%, rgba(105, 193, 187, .18), transparent 54%), linear-gradient(140deg, #0b2031, #081624); box-shadow: inset 0 1px rgba(255, 255, 255, .045); }.visual-gridline { stroke-dasharray: 2 5; }.visual-orbit { transform-origin: center; }.visual-core-beacon { filter: drop-shadow(0 0 5px currentColor); }.repository-visual:not(.compact):hover .orbit-one { animation: visual-orbit-spin 8s linear infinite; }.repository-visual:not(.compact):hover .orbit-two { animation: visual-orbit-spin 12s linear infinite reverse; }.workspace-hero { position: relative; isolation: isolate; border-color: rgba(132, 184, 207, .22); border-radius: 22px; background: radial-gradient(ellipse at 72% 15%, rgba(84, 177, 174, .17), transparent 39%), linear-gradient(120deg, rgba(16, 40, 56, .97), rgba(10, 23, 38, .97)); box-shadow: 0 24px 54px rgba(0,0,0,.18); }.workspace-hero::before { position: absolute; z-index: 0; top: -65px; right: 130px; width: 230px; height: 230px; border: 1px solid rgba(117, 209, 204, .1); border-radius: 50%; box-shadow: 0 0 0 28px rgba(117, 209, 204, .02); content: ''; }.workspace-hero > * { position: relative; z-index: 1; }.workspace-score { min-width: 100px; padding: 12px 0; border-right: 1px solid rgba(132, 184, 207, .13); }.workspace-score strong { text-shadow: 0 0 20px rgba(117, 209, 204, .25); }.workspace-visual { border-color: rgba(117, 209, 204, .26); box-shadow: 0 16px 30px rgba(0,0,0,.2); }.repository-tile { border-radius: 16px; background: linear-gradient(145deg, rgba(17, 37, 55, .92), rgba(8, 19, 32, .9)); }.repository-tile-main:hover:not(:disabled) { background: radial-gradient(ellipse at 80% 10%, rgba(117,209,204,.12), transparent 50%); }
@keyframes visual-orbit-spin { to { transform: rotate(360deg); } }
@media (max-width: 1180px) { .atlas-band { grid-template-columns: 168px minmax(0, 1fr); }.command-trend { margin-bottom: 2px; } }
@media (max-width: 760px) { .dashboard-command { min-height: 0; }.command-trend { margin-top: 12px; }.dashboard-readiness-rail { gap: 7px; }.readiness-rail-segment:hover:not(:disabled) { transform: none; }.attention-section { padding: 17px; }.attention-row { grid-template-columns: 20px 44px minmax(0, 1fr) auto; min-height: 62px; gap: 8px; }.attention-score small { display: none; }.attention-context em { font-size: 8px; }.atlas-bands { gap: 7px; padding: 7px; }.atlas-band { grid-template-columns: 1fr; }.atlas-band > header { padding: 12px 14px 12px 19px; }.atlas-lane-beacon { top: 14px; left: 9px; height: 25px; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(50px, 1fr)); gap: 5px; padding: 10px; }.atlas-repository { height: 50px; border-radius: 9px; transform: none; }.workspace-score { border-right: 0; border-bottom: 1px solid rgba(132, 184, 207, .13); }.workspace-hero::before { right: -45px; }.next-move-context { font-size: 8px; } }
@media (prefers-reduced-motion: reduce) { .readiness-rail-segment, .atlas-repository { transition: none !important; }.repository-visual:not(.compact):hover .orbit-one, .repository-visual:not(.compact):hover .orbit-two { animation: none !important; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { transform: none; } }
.overview-intelligence { display: grid; grid-template-columns: minmax(0, .83fr) minmax(0, 1.17fr); gap: 14px; }.change-brief > .muted { margin: 14px 0 2px; font-size: 11px; }.change-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 14px -18px -18px; border-top: 1px solid var(--border); background: var(--border); }.change-grid span { display: grid; gap: 4px; padding: 12px; color: var(--text-subtle); background: rgba(7, 17, 28, .28); font-size: 9px; text-transform: uppercase; }.change-grid b { color: var(--accent-bright); font-size: 17px; letter-spacing: -.04em; text-transform: none; }.top-findings-list { display: grid; gap: 2px; }.top-findings-list > div { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 9px; border-radius: 7px; padding: 7px 4px; }.top-findings-list > div:hover { background: rgba(117, 209, 204, .055); }.top-findings-list strong, .top-findings-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.top-findings-list strong { color: var(--text); font-size: 11px; }.top-findings-list small { margin-top: 2px; color: var(--text-subtle); font-size: 9px; }.top-findings-list b { color: var(--blocked); font-size: 11px; }
.architecture-workspace { padding: 0; overflow: hidden; }.architecture-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); padding: 12px 15px; color: var(--text-subtle); font-size: 10px; }.architecture-toolbar label { width: min(360px, 100%); }.architecture-toolbar input { min-height: 35px; }.architecture-main { display: grid; grid-template-columns: minmax(0, 1fr) 230px; min-height: 590px; }.architecture-workspace .architecture-canvas { height: auto; min-height: 590px; border: 0; border-radius: 0; }.architecture-inspector { border-left: 1px solid var(--border); padding: 17px; background: rgba(5, 14, 24, .35); }.architecture-inspector h3 { margin: 6px 0; color: var(--text); font-size: 15px; }.architecture-inspector > code { display: block; margin-bottom: 16px; overflow-wrap: anywhere; color: var(--accent-bright); font-size: 10px; }.architecture-inspector p { color: var(--text-muted); font-size: 11px; line-height: 1.5; }.architecture-inspector dl { display: grid; gap: 8px; margin: 0; }.architecture-inspector dl > div { display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 7px; }.architecture-inspector dt { color: var(--text-subtle); font-size: 10px; }.architecture-inspector dd { margin: 0; color: var(--text); font-size: 11px; }.react-flow__node.arch-selected { border-color: var(--accent) !important; box-shadow: 0 0 0 2px rgba(117, 209, 204, .14), 0 10px 28px rgba(0, 0, 0, .3) !important; }.react-flow__edge.animated .react-flow__edge-path { stroke: var(--accent) !important; }
@media (max-width: 760px) { .overview-intelligence { grid-template-columns: 1fr; }.change-grid { grid-template-columns: 1fr 1fr; }.architecture-main { grid-template-columns: 1fr; }.architecture-workspace .architecture-canvas { min-height: 460px; }.architecture-inspector { min-height: 160px; border-top: 1px solid var(--border); border-left: 0; }.architecture-toolbar { align-items: stretch; flex-direction: column; }.architecture-toolbar label { width: 100%; } }
/* v1.5: compact remediation queue driven only by retained backend evidence. */
.remediation-center { padding: 19px; background: linear-gradient(135deg, rgba(15, 38, 53, .94), rgba(8, 20, 34, .9)); }.remediation-live { color: var(--accent-bright); font-size: 9px; font-weight: 720; letter-spacing: .08em; text-transform: uppercase; }.remediation-list { display: grid; gap: 5px; margin: 10px 0 0; padding: 0; list-style: none; }.remediation-row { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; border: 1px solid rgba(132, 160, 193, .12); border-radius: 11px; padding: 11px; color: var(--text); background: rgba(5, 17, 29, .34); text-align: left; transition: border-color var(--motion) var(--ease), background var(--motion) var(--ease); }.remediation-row:hover:not(:disabled), .remediation-row:focus-visible { border-color: rgba(117, 209, 204, .42); background: rgba(117, 209, 204, .075); }.remediation-priority { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid rgba(237, 113, 136, .34); border-radius: 50%; color: var(--blocked); background: rgba(237, 113, 136, .09); font-size: 11px; font-weight: 800; }.remediation-copy { display: grid; min-width: 0; gap: 3px; }.remediation-copy strong, .remediation-copy small, .remediation-copy em, .remediation-copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.remediation-copy strong { font-size: 12px; }.remediation-copy small { color: var(--accent-bright); font-size: 9px; text-transform: capitalize; }.remediation-copy em { color: var(--text-muted); font-size: 10px; font-style: normal; }.remediation-copy b { color: var(--text-subtle); font-size: 9px; font-weight: 500; }.remediation-arrow { color: var(--accent); font-size: 18px; }.remediation-row:hover .remediation-arrow { transform: translateX(2px); }
@media (max-width: 760px) { .remediation-center { padding: 15px; }.remediation-row { grid-template-columns: 34px minmax(0, 1fr) auto; gap: 9px; padding: 9px; }.remediation-priority { width: 31px; height: 31px; }.remediation-copy b { display: none; } }
.repository-table th { position: static; }
.repository-table tbody tr { scroll-margin-top: 150px; }
+155
View File
@@ -0,0 +1,155 @@
import type { AppMeta, RepositoryDetail, RepositoryPolicy, RepositorySummary, ScanComparison, ScanReport, ScanSummary, TrendPoint } from './api'
export const testPolicy: RepositoryPolicy = {
auto_scan: true,
min_health_score: 70,
max_blockers: 0,
require_tests: true,
require_ci: true,
suppressions: [],
}
export function testRepository(id = 'gitea:64', fullName = 'Jens/RepoPulse', overrides: Partial<RepositorySummary> = {}): RepositorySummary {
const [owner, name] = fullName.split('/')
return {
id,
provider: id.startsWith('local:') ? 'local' : 'gitea',
provider_repository_id: id.split(':')[1] ?? id,
owner: owner ?? 'Jens',
name: name ?? fullName,
full_name: fullName,
description: 'Test repository',
html_url: null,
clone_url: null,
default_branch: 'main',
private: true,
archived: false,
available: true,
latest_revision: 'revision-1',
latest_revision_at: '2026-09-12T20:00:00Z',
last_synced_at: '2026-09-12T20:00:00Z',
sync_error: null,
state: 'current',
stale: false,
active_job_id: null,
last_scan_id: 'scan-1',
last_scan_at: '2026-09-12T20:00:00Z',
last_scan_health: 82,
last_scan_readiness: 'BLOCKED',
last_scan_revision: 'revision-1',
last_scan_blockers: 1,
last_scan_warnings: 0,
last_scan_primary_signal: { code: 'security.secret-assignment', category: 'Security basics', severity: 'blocker', message: 'Potential credential assignment detected; value intentionally redacted', count: 1 },
last_scan_health_delta: null,
last_scan_added_findings: 0,
last_scan_resolved_findings: 0,
policy: testPolicy,
...overrides,
}
}
export function testReport(overrides: Partial<ScanReport> = {}): ScanReport {
return {
id: 'scan-1',
repository_path: '/scan/repopulse',
repository_name: 'RepoPulse',
source: {
origin: 'gitea',
provider: 'gitea',
repository_id: '64',
repository_full_name: 'Jens/RepoPulse',
branch: 'main',
requested_revision: 'revision-1',
actual_revision: 'revision-1',
trigger: 'scheduled',
},
policy: testPolicy,
scanned_at: '2026-09-12T20:00:00Z',
duration_ms: 1200,
metrics: {
files: 2,
source_files: 2,
lines_of_code: 20,
test_files: 1,
todo: 0,
fixme: 0,
hack: 0,
xxx: 0,
dependency_manifests: 1,
declared_dependencies: 1,
large_files: 0,
large_source_files: 0,
},
languages: { TypeScript: 20 },
category_scores: { 'Security basics': 20, Maintainability: 80 },
health_score: 70,
release_readiness: 'BLOCKED',
findings: [{
code: 'security.secret-assignment',
category: 'Security basics',
severity: 'blocker',
message: 'Potential credential assignment detected; value intentionally redacted',
path: 'backend/app/main.py',
line: 245,
deduction: 20,
fingerprint: 'fingerprint-1',
suppressed: false,
}],
tests: { frameworks: ['pytest'], coverage_artifacts: [], ci_workflows: [], test_source_ratio: null },
git: { available: true, commit_count: 1, contributor_count: 1, latest_commit_at: null, tracked_files: 2, renamed_files: [] },
dependencies: { manifests: ['package.json'], lockfiles: ['package-lock.json'], declared_dependencies: 1, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [] },
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
hotspots: [],
release_gate: { status: 'BLOCKED', checks: [{ key: 'security', label: 'Security basics', state: 'block', detail: '1 blocker finding' }] },
...overrides,
}
}
export const testComparison: ScanComparison = {
current_scan_id: 'scan-1',
previous_scan_id: null,
health_score_delta: null,
category_score_deltas: {},
metric_deltas: {},
added_findings: [],
resolved_findings: [],
}
export const testScans: ScanSummary[] = [{
id: 'scan-1',
repository_id: 'gitea:64',
scanned_at: '2026-09-12T20:00:00Z',
branch: 'main',
requested_revision: 'revision-1',
actual_revision: 'revision-1',
trigger: 'scheduled',
health_score: 70,
release_readiness: 'BLOCKED',
findings: 1,
duration_ms: 1200,
}]
export const testTrend: TrendPoint[] = []
export const testMeta: AppMeta = {
name: 'RepoPulse',
version: '1.5.0',
stage: 'v1.5.0',
scan_root_restricted: true,
scan_root: '/scan',
max_concurrent_scans: 1,
repository_count: 2,
repository_states: { current: 2 },
current_repositories: 2,
stale_repositories: 0,
queue_depth: 0,
active_jobs: 0,
completed_jobs: 3,
failed_jobs: 0,
worker_heartbeat: '2026-09-12T20:00:00Z',
gitea: { configured: true, url: 'http://gitea.test', last_sync: { synced_at: '2026-09-12T20:00:00Z' } },
}
export function testDetail(repository = testRepository()): RepositoryDetail {
return { ...repository, latest_scan: testReport() }
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { configDefaults, defineConfig } from 'vitest/config'
export default defineConfig(({ mode }) => {
const environment = loadEnv(mode, '.', '')
const apiTarget = environment.REPOPULSE_API_PROXY || 'http://localhost:8000'
return {
plugins: [react()],
server: {
port: 5174,
proxy: {
'/api': apiTarget,
'/health': apiTarget,
},
},
test: {
exclude: [...configDefaults.exclude, 'e2e/**'],
},
}
})