Add full capability readiness matrix

This commit is contained in:
2026-06-30 14:37:02 +08:00
parent d9ea249ff0
commit 0c239483a9
8 changed files with 570 additions and 11 deletions

View File

@@ -67,6 +67,10 @@ The runtime panel calls `GET /api/system/readiness` and verifies the conda
imports required for the backend/task environment and the full MMSeg/mmcv
environment. Command-line verification is available with
`PYTHONPATH=backend conda run -n seg_smp python scripts/verify_runtime_envs.py --refresh`.
The capability matrix calls `GET /api/capabilities` and summarizes readiness
for Dataset, SegModel, YOLO, MMSeg, visual tools, analysis, and system
operations, including task coverage, runtime status, uploaded datasets,
heatmap/segmentation artifacts, training curves, and weight manifest status.
The same panel can run `POST /api/acceptance/smoke`, a lightweight live smoke
that creates an upload dataset, uploads a label, downloads it through the
@@ -144,6 +148,8 @@ Use `GET /api/catalog` to inspect supported models, algorithms, datasets, and
task types discovered from the existing `Seg/` workspace.
Use `GET /api/coverage` to inspect script-to-task coverage and task
buildability.
Use `GET /api/capabilities` to inspect the grouped full-function readiness
matrix used by the web dashboard and agents.
Use `GET /api/results/curves` to inspect parsed training curves discovered
from YOLO, SegModel, MMSeg, visual-tool, and analysis output directories.
@@ -155,10 +161,10 @@ Run the local evaluation and validation agents before publishing changes:
PYTHONPATH=backend conda run -n seg_smp python scripts/run_agents.py --build
```
The validation agent checks catalog coverage, the `seg_smp` task env, the
`seg_mmcv` MMSeg env, runtime import readiness, GPU visibility, no-weight Git
safety, backend tests, frontend build, and live backend/frontend endpoints
when the services are running. With live validation enabled it also runs the
lightweight acceptance smoke above. By default it also runs the deep training
acceptance; set `SEG_VALIDATE_DEEP=0` when a quick non-training validation
pass is needed.
The validation agent checks catalog coverage, the grouped capability matrix,
the `seg_smp` task env, the `seg_mmcv` MMSeg env, runtime import readiness,
GPU visibility, no-weight Git safety, backend tests, frontend build, and live
backend/frontend endpoints when the services are running. With live validation
enabled it also runs the lightweight acceptance smoke above. By default it
also runs the deep training acceptance; set `SEG_VALIDATE_DEEP=0` when a quick
non-training validation pass is needed.

View File

@@ -44,10 +44,12 @@ def evaluate_project() -> dict:
"loss_result_ui": "loss" in frontend_text.lower() and "heatmap" in frontend_text.lower() and "CurvePanel" in frontend_text,
"job_progress_ui": "JobProgressBar" in frontend_text and "progressTrack" in frontend_text,
"runtime_readiness_ui": "runtimeReadiness" in frontend_text and "环境就绪" in frontend_text,
"capability_matrix_ui": "capabilities" in frontend_text and "全功能矩阵" in frontend_text,
"dataset_api": "/api/datasets" in backend_text and "api_upload_dataset_files" in backend_text,
"dataset_quality_api": "/api/datasets/{dataset_name}/validate" in backend_text and "/api/datasets/{dataset_name}/yolo-yaml" in backend_text,
"job_progress_api": "progress_from_log_path" in backend_text and '"progress"' in backend_text,
"runtime_readiness_api": "/api/system/readiness" in backend_text,
"capability_matrix_api": "/api/capabilities" in backend_text,
"runtime_bootstrap_scripts": (settings.project_root / "scripts" / "bootstrap_conda_envs.sh").exists()
and (settings.project_root / "scripts" / "verify_runtime_envs.py").exists(),
"curve_api": "/api/results/curves" in backend_text,

View File

@@ -9,6 +9,7 @@ import urllib.request
from pathlib import Path
from ..acceptance import run_deep_acceptance, run_live_acceptance
from ..capabilities import get_capability_matrix
from ..catalog import get_catalog
from ..config import settings
from ..coverage import get_coverage_report
@@ -66,6 +67,12 @@ def validate_project(run_build: bool = False) -> dict:
checks.append({"name": "mmseg_env_exists", "passed": settings.mmseg_conda_env in env_names, "detail": {"env": settings.mmseg_conda_env}})
runtime_readiness = get_runtime_readiness(force=True)
checks.append({"name": "runtime_env_readiness", "passed": runtime_readiness["passed"], "detail": runtime_readiness})
capability_matrix = get_capability_matrix()
checks.append({
"name": "capability_matrix_ready",
"passed": capability_matrix["passed"] and capability_matrix["summary"]["ready_domains"] == capability_matrix["summary"]["total_domains"],
"detail": capability_matrix,
})
smoke = _run(
[
@@ -105,6 +112,7 @@ def validate_project(run_build: bool = False) -> dict:
datasets = _fetch(f"{backend_url}/api/datasets")
live_jobs = _fetch(f"{backend_url}/api/jobs")
live_readiness = _fetch(f"{backend_url}/api/system/readiness")
live_capabilities = _fetch(f"{backend_url}/api/capabilities")
live_coverage = _fetch(f"{backend_url}/api/coverage")
live_curves = _fetch(f"{backend_url}/api/results/curves")
frontend = _fetch(frontend_url)
@@ -124,6 +132,11 @@ def validate_project(run_build: bool = False) -> dict:
"passed": live_readiness["passed"] and '"passed":true' in live_readiness.get("body", "").replace(" ", ""),
"detail": live_readiness,
})
checks.append({
"name": "live_capability_matrix_api",
"passed": live_capabilities["passed"] and '"passed":true' in live_capabilities.get("body", "").replace(" ", ""),
"detail": live_capabilities,
})
checks.append({"name": "live_coverage_api", "passed": live_coverage["passed"] and '"task_build_passed":true' in live_coverage.get("body", "").replace(" ", ""), "detail": live_coverage})
checks.append({"name": "live_training_curves_api", "passed": live_curves["passed"] and live_curves.get("body", "").lstrip().startswith("["), "detail": live_curves})
checks.append({"name": "live_frontend_index", "passed": frontend["passed"] and "Seg Data Server" in frontend.get("body", ""), "detail": frontend})

299
backend/app/capabilities.py Normal file
View File

@@ -0,0 +1,299 @@
from __future__ import annotations
import time
from typing import Any
from .acceptance import latest_acceptance_report, latest_deep_acceptance_report
from .catalog import get_catalog
from .coverage import get_coverage_report
from .modules.dataset.service import list_uploaded_datasets
from .modules.results.service import scan_results, scan_training_curves
from .modules.system.service import get_gpus, get_runtime_readiness
from .modules.weights.service import load_manifest
CAPABILITY_GROUPS = [
{
"id": "dataset",
"label": "Dataset",
"description": "上传图片、label、mask并执行预处理、配对、叠加、拼接、视频抽帧和 YOLO 数据构建。",
"required_tasks": [
"dataset.rename",
"dataset.to_png",
"dataset.resize",
"dataset.pair",
"dataset.rebuild_labels",
"dataset.stack",
"dataset.stitch",
"dataset.video_frames",
"dataset.yolo_txt_sort",
"dataset.yolo_convert_png",
"dataset.yolo_resize",
],
"task_prefixes": ["dataset."],
"evidence_roles": ["segmentation"],
},
{
"id": "segmodel",
"label": "SegModel",
"description": "SMP/SegModel 单模型和批量训练、预测、raw mask、指标、FLOPs/FPS。",
"required_tasks": [
"segmodel.train",
"segmodel.batch_train",
"segmodel.predict",
"segmodel.batch_predict",
"segmodel.flops",
"segmodel.benchmark",
"segmodel.raw_mask_check",
"segmodel.metrics",
],
"task_prefixes": ["segmodel."],
"runtime_roles": ["backend_task"],
"families": ["segmodel"],
},
{
"id": "yolo",
"label": "YOLO",
"description": "YOLOv8/v9/v11/v12 分割训练、上传数据集训练、预测、热度图、对比、视频预测和 raw mask。",
"required_tasks": [
"yolo.train",
"yolo.train_custom",
"yolo.predict",
"yolo.batch_predict",
"yolo.heatmap",
"yolo.compare",
"yolo.raw_mask_check",
"yolo.video_visible",
"yolo.video_unvisible",
],
"task_prefixes": ["yolo."],
"runtime_roles": ["backend_task"],
"families": ["yolo"],
"evidence_roles": ["heatmap", "segmentation", "curve", "video"],
},
{
"id": "mmseg",
"label": "MMSeg",
"description": "MMSeg 数据/算法配置生成、31 个算法、训练、预测、指标、FLOPs/FPS、绘图和 loss/mIoU 提取。",
"required_tasks": [
"mmseg.generate_data",
"mmseg.generate_alg",
"mmseg.train",
"mmseg.predict_v1",
"mmseg.predict_v2",
"mmseg.metrics",
"mmseg.flops_fps",
"mmseg.draw",
"mmseg.extract_loss_miou",
],
"task_prefixes": ["mmseg."],
"runtime_roles": ["mmseg_full"],
"families": ["mmseg"],
},
{
"id": "visual",
"label": "Visual Tools",
"description": "可视化工具训练、推理、FPS、YOLO11 热度图、label 转换和 8-bit PNG 生成。",
"required_tasks": [
"visual.train",
"visual.inference",
"visual.fps",
"visual.yolo11_heatmap_v1",
"visual.yolo11_heatmap_v2",
"visual.deal_labels",
],
"task_prefixes": ["visual."],
"runtime_roles": ["backend_task"],
"families": ["tool"],
"evidence_roles": ["heatmap", "segmentation"],
},
{
"id": "analysis",
"label": "Analysis",
"description": "合并 SegModel/MMSeg/YOLO 指标,生成 CSV、表格、图表和性能摘要。",
"required_tasks": ["analysis.all"],
"task_prefixes": ["analysis."],
"families": ["analysis"],
},
{
"id": "system",
"label": "System",
"description": "GPU 查询、环境检查、权重同步、任务日志/进度、取消和备份入口。",
"required_tasks": ["system.backup", "system.check_graph_card"],
"task_prefixes": ["system.", "mock."],
"runtime_roles": ["backend_task", "mmseg_full"],
},
]
def _tasks_for_group(all_tasks: list[str], prefixes: list[str]) -> list[str]:
return sorted(task for task in all_tasks if any(task.startswith(prefix) for prefix in prefixes))
def _artifact_matches(item: dict[str, Any], group: dict[str, Any]) -> bool:
families = set(group.get("families", []))
roles = set(group.get("evidence_roles", []))
return (not families or item.get("family") in families) and (not roles or item.get("role") in roles)
def _coverage_build_task_set(coverage: dict[str, Any]) -> set[str]:
return {item["task"] for item in coverage.get("task_build_checks", []) if item.get("passed")}
def _runtime_map(readiness: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {item["role"]: item for item in readiness.get("envs", [])}
def get_capability_matrix() -> dict[str, Any]:
catalog = get_catalog()
coverage = get_coverage_report()
readiness = get_runtime_readiness(force=False)
results = scan_results(limit=1000)
curves = scan_training_curves(limit=50)
datasets = list_uploaded_datasets()
manifest = load_manifest()
gpus = get_gpus()
acceptance = latest_acceptance_report()
deep_acceptance = latest_deep_acceptance_report()
all_tasks = catalog["task_types"]
buildable_tasks = _coverage_build_task_set(coverage)
runtime_by_role = _runtime_map(readiness)
domains = []
for group in CAPABILITY_GROUPS:
group_tasks = _tasks_for_group(all_tasks, group["task_prefixes"])
required_tasks = group["required_tasks"]
missing_required = [task for task in required_tasks if task not in all_tasks]
unbuildable_required = [task for task in required_tasks if task in all_tasks and task not in buildable_tasks]
runtime_roles = group.get("runtime_roles", [])
runtime_reports = [runtime_by_role.get(role) for role in runtime_roles if runtime_by_role.get(role)]
runtime_ready = all(item.get("passed") for item in runtime_reports) if runtime_roles else True
artifacts = [item for item in results if _artifact_matches(item, group)]
group_curves = [item for item in curves if not group.get("families") or item.get("family") in group["families"]]
evidence_count = len(artifacts) + len(group_curves)
if group["id"] == "dataset":
evidence_count += sum(dataset["counts"]["images"] + dataset["counts"]["labels"] + dataset["counts"]["masks"] for dataset in datasets)
if group["id"] == "mmseg":
evidence_count += len(catalog.get("mmseg_algorithms", []))
if group["id"] == "system":
evidence_count += int(bool(gpus.get("available"))) + int(manifest.get("count", 0) > 0)
gaps = []
if missing_required:
gaps.append(f"missing tasks: {', '.join(missing_required[:4])}")
if unbuildable_required:
gaps.append(f"unbuildable tasks: {', '.join(unbuildable_required[:4])}")
if runtime_roles and not runtime_ready:
gaps.append("runtime env check failed")
domains.append(
{
"id": group["id"],
"label": group["label"],
"description": group["description"],
"ready": not missing_required and not unbuildable_required and runtime_ready,
"tasks": {
"total": len(group_tasks),
"required": len(required_tasks),
"required_ready": len(required_tasks) - len(missing_required) - len(unbuildable_required),
"examples": group_tasks[:10],
"missing_required": missing_required,
"unbuildable_required": unbuildable_required,
},
"runtime": [
{"role": item["role"], "name": item["name"], "passed": item["passed"]}
for item in runtime_reports
],
"evidence": {
"count": evidence_count,
"artifacts": [
{
"name": item["name"],
"relative_path": item["relative_path"],
"role": item.get("role"),
"family": item.get("family"),
}
for item in artifacts[:6]
],
"curves": [
{
"name": item["name"],
"relative_path": item["relative_path"],
"family": item.get("family"),
"row_count": item.get("row_count"),
}
for item in group_curves[:4]
],
},
"gaps": gaps,
}
)
requirements = [
{
"id": "user_script_mapping",
"label": "用户侧脚本映射",
"passed": coverage["mapped_user_scripts"] == coverage["user_scripts_total"] and not coverage["unmapped_user_scripts"],
"detail": f"{coverage['mapped_user_scripts']}/{coverage['user_scripts_total']}",
},
{
"id": "runtime_readiness",
"label": "运行环境就绪",
"passed": readiness.get("passed", False),
"detail": ", ".join(f"{item['name']}={item['passed']}" for item in readiness.get("envs", [])),
},
{
"id": "dataset_upload",
"label": "数据集上传/Label/Mask",
"passed": len(datasets) >= 1,
"detail": f"{len(datasets)} uploaded dataset(s)",
},
{
"id": "yolo_heatmap",
"label": "YOLO 热度图证据",
"passed": any(item.get("family") == "yolo" and item.get("role") == "heatmap" for item in results),
"detail": "heatmap artifact discovered",
},
{
"id": "training_curves",
"label": "训练 loss/指标曲线",
"passed": len(curves) >= 1,
"detail": f"{len(curves)} curve file(s)",
},
{
"id": "deep_acceptance",
"label": "深度训练验收",
"passed": bool(deep_acceptance.get("passed")),
"detail": deep_acceptance.get("run_id", "not run"),
},
{
"id": "weights_manifest",
"label": "权重清单",
"passed": manifest.get("count", 0) >= 1,
"detail": f"{manifest.get('count', 0)} weights indexed",
},
]
ready_domains = sum(1 for item in domains if item["ready"])
return {
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"passed": ready_domains == len(domains) and all(item["passed"] for item in requirements),
"summary": {
"ready_domains": ready_domains,
"total_domains": len(domains),
"mapped_user_scripts": coverage["mapped_user_scripts"],
"user_scripts_total": coverage["user_scripts_total"],
"task_build_passed": coverage["task_build_passed"],
"uploaded_datasets": len(datasets),
"artifacts": len(results),
"curves": len(curves),
"weights": manifest.get("count", 0),
"gpus_available": bool(gpus.get("available")),
"acceptance_passed": bool(acceptance.get("passed")),
"deep_acceptance_passed": bool(deep_acceptance.get("passed")),
},
"requirements": requirements,
"domains": domains,
}

View File

@@ -10,6 +10,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from . import db
from .acceptance import latest_acceptance_report, latest_deep_acceptance_report, run_deep_acceptance, run_live_acceptance
from .capabilities import get_capability_matrix
from .catalog import get_catalog
from .config import settings
from .coverage import get_coverage_report
@@ -86,6 +87,11 @@ def api_coverage() -> dict:
return get_coverage_report()
@app.get("/api/capabilities")
def api_capabilities() -> dict:
return get_capability_matrix()
@app.get("/api/acceptance/latest")
def api_acceptance_latest() -> dict:
return latest_acceptance_report()

View File

@@ -0,0 +1,21 @@
from app.capabilities import get_capability_matrix
def test_capability_matrix_covers_core_domains():
matrix = get_capability_matrix()
domains = {item["id"]: item for item in matrix["domains"]}
assert {"dataset", "segmodel", "yolo", "mmseg", "visual", "analysis", "system"} <= set(domains)
assert domains["dataset"]["tasks"]["required_ready"] == domains["dataset"]["tasks"]["required"]
assert domains["yolo"]["ready"] is True
assert domains["mmseg"]["ready"] is True
def test_capability_matrix_tracks_user_requirements():
matrix = get_capability_matrix()
requirements = {item["id"]: item for item in matrix["requirements"]}
assert requirements["user_script_mapping"]["passed"] is True
assert requirements["runtime_readiness"]["passed"] is True
assert requirements["yolo_heatmap"]["passed"] is True
assert requirements["training_curves"]["passed"] is True

View File

@@ -193,6 +193,44 @@ type RuntimeReadinessPayload = {
};
};
type CapabilityDomain = {
id: string;
label: string;
ready: boolean;
tasks: {
total: number;
required: number;
required_ready: number;
examples: string[];
missing_required: string[];
unbuildable_required: string[];
};
runtime: Array<{ role: string; name: string; passed: boolean }>;
evidence: {
count: number;
artifacts: Array<{ name: string; relative_path: string; role?: string; family?: string }>;
curves: Array<{ name: string; relative_path: string; family?: string; row_count?: number }>;
};
gaps: string[];
};
type CapabilityPayload = {
passed: boolean;
generated_at: string;
summary: {
ready_domains: number;
total_domains: number;
mapped_user_scripts: number;
user_scripts_total: number;
uploaded_datasets: number;
artifacts: number;
curves: number;
weights: number;
};
requirements: Array<{ id: string; label: string; passed: boolean; detail: string }>;
domains: CapabilityDomain[];
};
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
@@ -266,14 +304,16 @@ function useData() {
const [acceptance, setAcceptance] = useState<AcceptancePayload | null>(null);
const [deepAcceptance, setDeepAcceptance] = useState<DeepAcceptancePayload | null>(null);
const [runtimeReadiness, setRuntimeReadiness] = useState<RuntimeReadinessPayload | null>(null);
const [capabilities, setCapabilities] = useState<CapabilityPayload | null>(null);
const [error, setError] = useState<string>("");
async function refresh() {
try {
const [catalogNext, gpusNext, readinessNext, jobsNext, resultsNext, curvesNext, datasetsNext, coverageNext, acceptanceNext, deepAcceptanceNext] = await Promise.all([
const [catalogNext, gpusNext, readinessNext, capabilitiesNext, jobsNext, resultsNext, curvesNext, datasetsNext, coverageNext, acceptanceNext, deepAcceptanceNext] = await Promise.all([
api<Catalog>("/api/catalog"),
api<GpuPayload>("/api/system/gpus"),
api<RuntimeReadinessPayload>("/api/system/readiness"),
api<CapabilityPayload>("/api/capabilities"),
api<Job[]>("/api/jobs"),
api<ResultItem[]>("/api/results"),
api<TrainingCurve[]>("/api/results/curves"),
@@ -285,6 +325,7 @@ function useData() {
setCatalog(catalogNext);
setGpus(gpusNext);
setRuntimeReadiness(readinessNext);
setCapabilities(capabilitiesNext);
setJobs(jobsNext);
setResults(resultsNext.slice(0, 80));
setCurves(curvesNext.slice(0, 12));
@@ -316,7 +357,7 @@ function useData() {
return () => window.clearInterval(timer);
}, []);
return { catalog, gpus, runtimeReadiness, jobs, results, curves, datasets, datasetValidations, coverage, acceptance, deepAcceptance, error, refresh };
return { catalog, gpus, runtimeReadiness, capabilities, jobs, results, curves, datasets, datasetValidations, coverage, acceptance, deepAcceptance, error, refresh };
}
function StatusPill({ status }: { status: string }) {
@@ -339,7 +380,7 @@ function JobProgressBar({ progress }: { progress?: JobProgress }) {
}
function App() {
const { catalog, gpus, runtimeReadiness, jobs, results, curves, datasets, datasetValidations, coverage, acceptance, deepAcceptance, error, refresh } = useData();
const { catalog, gpus, runtimeReadiness, capabilities, jobs, results, curves, datasets, datasetValidations, coverage, acceptance, deepAcceptance, error, refresh } = useData();
const [taskType, setTaskType] = useState("mock.echo");
const [params, setParams] = useState(JSON.stringify(defaultParams["mock.echo"], null, 2));
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
@@ -529,6 +570,7 @@ function App() {
</div>
<nav>
<a href="#jobs"><Terminal size={18} /></a>
<a href="#capabilities"><Gauge size={18} /></a>
<a href="#datasets"><Boxes size={18} /></a>
<a href="#gpu"><Cpu size={18} />GPU</a>
<a href="#runtime"><ShieldCheck size={18} /></a>
@@ -579,6 +621,48 @@ function App() {
</div>
</section>
<section className="panel" id="capabilities">
<div className="panelHead">
<div>
<p className="eyebrow">Capability Matrix</p>
<h2></h2>
</div>
<StatusPill status={capabilities?.passed ? "success" : "queued"} />
</div>
<div className="capSummary">
<div><span></span><strong>{capabilities?.summary.ready_domains ?? 0}/{capabilities?.summary.total_domains ?? 0}</strong></div>
<div><span></span><strong>{capabilities?.summary.mapped_user_scripts ?? 0}/{capabilities?.summary.user_scripts_total ?? 0}</strong></div>
<div><span></span><strong>{capabilities?.summary.uploaded_datasets ?? 0}</strong></div>
<div><span></span><strong>{capabilities?.summary.artifacts ?? 0}</strong></div>
<div><span>线</span><strong>{capabilities?.summary.curves ?? 0}</strong></div>
<div><span></span><strong>{capabilities?.summary.weights ?? 0}</strong></div>
</div>
<div className="capabilityGrid">
{(capabilities?.domains ?? []).map((domain) => (
<div key={domain.id} className={`capCard ${domain.ready ? "ok" : "bad"}`}>
<div className="capHead">
<strong>{domain.label}</strong>
<span>{domain.ready ? "READY" : "CHECK"}</span>
</div>
<div className="capNumbers">
<span>{domain.tasks.required_ready}/{domain.tasks.required} required</span>
<span>{domain.evidence.count} evidence</span>
</div>
<div className="capTags">
{domain.tasks.examples.slice(0, 4).map((task) => <small key={task}>{task}</small>)}
</div>
</div>
))}
</div>
<div className="requirementStrip">
{(capabilities?.requirements ?? []).map((item) => (
<span key={item.id} className={item.passed ? "ok" : "bad"} title={item.detail}>
{item.label}
</span>
))}
</div>
</section>
<section className="grid two" id="jobs">
<div className="panel taskPanel">
<div className="panelHead">

View File

@@ -186,6 +186,131 @@ h2 {
margin-bottom: 16px;
}
.capSummary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.capSummary div {
min-width: 0;
padding: 10px;
border-radius: 7px;
border: 1px solid var(--line);
background: #101310;
}
.capSummary span,
.capSummary strong {
display: block;
}
.capSummary span {
color: var(--muted);
font-size: 11px;
}
.capSummary strong {
margin-top: 3px;
font-size: 20px;
}
.capabilityGrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 9px;
}
.capCard {
min-width: 0;
display: grid;
gap: 8px;
padding: 10px;
border-radius: 7px;
border: 1px solid var(--line);
background: #101310;
}
.capCard.ok {
border-color: rgba(157, 226, 111, 0.32);
}
.capCard.bad {
border-color: rgba(240, 113, 103, 0.55);
}
.capHead {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.capHead strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.capHead span {
color: var(--green);
font-size: 11px;
font-weight: 760;
}
.capCard.bad .capHead span {
color: var(--red);
}
.capNumbers {
display: grid;
gap: 3px;
color: var(--muted);
font-size: 11px;
}
.capTags {
display: grid;
gap: 4px;
}
.capTags small {
min-width: 0;
padding: 4px 5px;
border-radius: 4px;
background: #080a08;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.requirementStrip {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.requirementStrip span {
padding: 5px 7px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: #101310;
font-size: 11px;
}
.requirementStrip span.ok {
color: var(--green);
border-color: rgba(157, 226, 111, 0.32);
}
.requirementStrip span.bad {
color: var(--red);
border-color: rgba(240, 113, 103, 0.55);
}
.metric, .panel {
border: 1px solid var(--line);
background: rgba(21, 24, 21, 0.94);
@@ -958,7 +1083,7 @@ meter {
}
nav {
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: center;
}
@@ -985,8 +1110,11 @@ meter {
}
.metrics,
.capSummary,
.capabilityGrid,
.grid.two,
.grid.three,
.grid.four,
.taskColumns {
grid-template-columns: 1fr;
}