portable-agent-layer 0.37.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/skills/consulting-report/SKILL.md +40 -1
- package/assets/skills/consulting-report/template/app/globals.css +51 -362
- package/assets/skills/consulting-report/template/app/layout.tsx +2 -8
- package/assets/skills/consulting-report/template/components/band-badge.tsx +72 -0
- package/assets/skills/consulting-report/template/components/callout.tsx +5 -3
- package/assets/skills/consulting-report/template/components/comparison-table.tsx +13 -7
- package/assets/skills/consulting-report/template/components/configuration-table.tsx +87 -0
- package/assets/skills/consulting-report/template/components/cover-page.tsx +18 -6
- package/assets/skills/consulting-report/template/components/exhibit.tsx +7 -5
- package/assets/skills/consulting-report/template/components/finding-card.tsx +7 -5
- package/assets/skills/consulting-report/template/components/quote-block.tsx +13 -3
- package/assets/skills/consulting-report/template/components/recommendation-card.tsx +6 -4
- package/assets/skills/consulting-report/template/components/rubric-table.tsx +104 -0
- package/assets/skills/consulting-report/template/components/score-badge.tsx +51 -0
- package/assets/skills/consulting-report/template/components/scorecard.tsx +154 -0
- package/assets/skills/consulting-report/template/components/section.tsx +16 -1
- package/assets/skills/consulting-report/template/components/severity-badge.tsx +20 -5
- package/assets/skills/consulting-report/template/components/stat-grid.tsx +11 -5
- package/assets/skills/consulting-report/template/components/table-of-contents.tsx +17 -7
- package/assets/skills/consulting-report/template/components/template-block.tsx +20 -0
- package/assets/skills/consulting-report/template/components/timeline.tsx +25 -6
- package/assets/skills/consulting-report/template/components/tuning-log.tsx +87 -0
- package/assets/skills/consulting-report/template/lib/report-data.ts +26 -74
- package/assets/skills/consulting-report/template/lib/types.ts +190 -0
- package/package.json +3 -2
- package/src/cli/index.ts +1 -3
- package/src/hooks/lib/graduation.ts +1 -1
- package/src/hooks/lib/work-tracking.ts +5 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ConfigurationParameter } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
export type ConfigurationColumn = {
|
|
4
|
+
/** Property of `ConfigurationParameter` to read for this column. */
|
|
5
|
+
key: keyof ConfigurationParameter;
|
|
6
|
+
/** Header text shown in the table. */
|
|
7
|
+
header: string;
|
|
8
|
+
/** Optional className applied to all body cells in this column. */
|
|
9
|
+
cellClassName?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const defaultColumns: ConfigurationColumn[] = [
|
|
13
|
+
{ key: "name", header: "Parameter", cellClassName: "font-sans font-semibold w-[28%]" },
|
|
14
|
+
{ key: "currentValue", header: "Current value", cellClassName: "w-[28%]" },
|
|
15
|
+
{
|
|
16
|
+
key: "location",
|
|
17
|
+
header: "Location",
|
|
18
|
+
cellClassName: "w-[18%] font-sans text-[0.78rem] text-muted",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
key: "rationale",
|
|
22
|
+
header: "Tuning rationale",
|
|
23
|
+
cellClassName: "w-[26%] text-muted text-[0.82rem] italic",
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
interface ConfigurationTableProps {
|
|
28
|
+
parameters: ConfigurationParameter[];
|
|
29
|
+
/**
|
|
30
|
+
* Override the default 4-column layout. Pass a different array of
|
|
31
|
+
* columns to render any subset / superset of `ConfigurationParameter`
|
|
32
|
+
* fields. Defaults to: Parameter, Current value, Location, Tuning rationale.
|
|
33
|
+
*/
|
|
34
|
+
columns?: ConfigurationColumn[];
|
|
35
|
+
/**
|
|
36
|
+
* Default label for the "tunable" marker, used when a parameter does not
|
|
37
|
+
* provide its own `tunableLabel`. Defaults to "TUNABLE".
|
|
38
|
+
*/
|
|
39
|
+
defaultTunableLabel?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tunableClass =
|
|
43
|
+
"inline-block ml-2 px-1.5 py-px bg-warning/10 text-warning font-sans text-[0.6rem] font-bold uppercase tracking-widest border border-warning/30 rounded align-middle";
|
|
44
|
+
|
|
45
|
+
export function ConfigurationTable({
|
|
46
|
+
parameters,
|
|
47
|
+
columns = defaultColumns,
|
|
48
|
+
defaultTunableLabel = "TUNABLE",
|
|
49
|
+
}: ConfigurationTableProps) {
|
|
50
|
+
const thClass =
|
|
51
|
+
"font-sans text-[0.68rem] font-bold uppercase tracking-widest text-primary px-2.5 py-2 border-b-2 border-primary text-left";
|
|
52
|
+
const tdClass = "px-2.5 py-2 border-b border-border-subtle align-top";
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<table className="w-full border-collapse my-4 font-body text-[0.85rem] break-inside-avoid">
|
|
56
|
+
<thead>
|
|
57
|
+
<tr>
|
|
58
|
+
{columns.map((c) => (
|
|
59
|
+
<th key={String(c.key)} className={thClass}>
|
|
60
|
+
{c.header}
|
|
61
|
+
</th>
|
|
62
|
+
))}
|
|
63
|
+
</tr>
|
|
64
|
+
</thead>
|
|
65
|
+
<tbody>
|
|
66
|
+
{parameters.map((p) => (
|
|
67
|
+
<tr key={p.name}>
|
|
68
|
+
{columns.map((c) => {
|
|
69
|
+
const value = p[c.key];
|
|
70
|
+
const isNameCol = c.key === "name";
|
|
71
|
+
return (
|
|
72
|
+
<td key={String(c.key)} className={`${tdClass} ${c.cellClassName ?? ""}`}>
|
|
73
|
+
{value ?? (c.key === "rationale" ? "—" : "")}
|
|
74
|
+
{isNameCol && p.tunable && (
|
|
75
|
+
<span className={tunableClass}>
|
|
76
|
+
{p.tunableLabel ?? defaultTunableLabel}
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
</td>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</tr>
|
|
83
|
+
))}
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -16,17 +16,29 @@ export function CoverPage({
|
|
|
16
16
|
preTitle = "Strategic Assessment",
|
|
17
17
|
}: CoverPageProps) {
|
|
18
18
|
return (
|
|
19
|
-
<div
|
|
20
|
-
|
|
19
|
+
<div
|
|
20
|
+
className={[
|
|
21
|
+
"min-h-screen flex flex-col justify-center p-16",
|
|
22
|
+
"break-after-page",
|
|
23
|
+
"bg-gradient-to-b from-background to-background-secondary",
|
|
24
|
+
].join(" ")}
|
|
25
|
+
>
|
|
26
|
+
<div className="font-sans text-sm font-semibold text-destructive uppercase tracking-[0.15em] mb-16">
|
|
27
|
+
{classification}
|
|
28
|
+
</div>
|
|
21
29
|
|
|
22
30
|
<div className="text-sm tracking-[0.25em] text-primary uppercase mb-4 font-semibold font-sans">
|
|
23
31
|
{preTitle}
|
|
24
32
|
</div>
|
|
25
|
-
<h1 className="
|
|
26
|
-
|
|
33
|
+
<h1 className="font-heading text-5xl font-semibold text-foreground leading-tight mb-4 tracking-tight">
|
|
34
|
+
{reportTitle}
|
|
35
|
+
</h1>
|
|
36
|
+
<p className="font-heading text-2xl text-muted mb-16 font-normal">
|
|
37
|
+
Prepared for {clientName}
|
|
38
|
+
</p>
|
|
27
39
|
|
|
28
|
-
<div className="
|
|
29
|
-
<p className="
|
|
40
|
+
<div className="mt-auto pt-6 border-t border-border">
|
|
41
|
+
<p className="font-sans text-base text-muted">{reportDate}</p>
|
|
30
42
|
<p className="text-muted-dark text-sm mt-2 font-sans">{consultancyName}</p>
|
|
31
43
|
</div>
|
|
32
44
|
</div>
|
|
@@ -7,15 +7,17 @@ interface ExhibitProps {
|
|
|
7
7
|
|
|
8
8
|
export function Exhibit({ number, title, source, children }: ExhibitProps) {
|
|
9
9
|
return (
|
|
10
|
-
<div className="
|
|
11
|
-
<div className="
|
|
10
|
+
<div className="bg-background-secondary border border-border rounded-lg p-6 my-6 break-inside-avoid">
|
|
11
|
+
<div className="flex justify-between items-baseline mb-4 pb-2 border-b border-border-subtle">
|
|
12
12
|
<div>
|
|
13
|
-
<span className="
|
|
14
|
-
|
|
13
|
+
<span className="font-sans font-semibold text-primary text-sm uppercase tracking-widest">
|
|
14
|
+
Exhibit {number}
|
|
15
|
+
</span>
|
|
16
|
+
<span className="font-heading font-semibold text-foreground ml-3">{title}</span>
|
|
15
17
|
</div>
|
|
16
18
|
{source && <span className="text-xs text-muted italic">Source: {source}</span>}
|
|
17
19
|
</div>
|
|
18
|
-
<div
|
|
20
|
+
<div>{children}</div>
|
|
19
21
|
</div>
|
|
20
22
|
);
|
|
21
23
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Finding } from "@/lib/
|
|
1
|
+
import type { Finding } from "@/lib/types";
|
|
2
2
|
import { SeverityBadge } from "./severity-badge";
|
|
3
3
|
|
|
4
4
|
interface FindingCardProps {
|
|
@@ -8,18 +8,20 @@ interface FindingCardProps {
|
|
|
8
8
|
|
|
9
9
|
export function FindingCard({ finding, index }: FindingCardProps) {
|
|
10
10
|
return (
|
|
11
|
-
<div className="
|
|
12
|
-
<div className="
|
|
11
|
+
<div className="bg-background-secondary border border-border rounded-lg p-6 mb-4 break-inside-avoid">
|
|
12
|
+
<div className="flex justify-between items-start mb-3">
|
|
13
13
|
<div className="flex items-center gap-4">
|
|
14
14
|
<span className="text-primary font-bold font-sans text-2xl min-w-8">
|
|
15
15
|
{index + 1}.
|
|
16
16
|
</span>
|
|
17
|
-
<span className="
|
|
17
|
+
<span className="font-heading font-semibold text-foreground text-base">
|
|
18
|
+
{finding.title}
|
|
19
|
+
</span>
|
|
18
20
|
</div>
|
|
19
21
|
<SeverityBadge severity={finding.severity} />
|
|
20
22
|
</div>
|
|
21
23
|
<p className="text-foreground mb-2 ml-12">{finding.description}</p>
|
|
22
|
-
<p className="
|
|
24
|
+
<p className="text-sm text-muted mt-2 ml-12">
|
|
23
25
|
<span className="font-medium text-foreground">Evidence:</span> {finding.evidence}
|
|
24
26
|
</p>
|
|
25
27
|
<p className="text-xs text-muted mt-2 italic ml-12">Source: {finding.source}</p>
|
|
@@ -6,9 +6,19 @@ interface QuoteBlockProps {
|
|
|
6
6
|
|
|
7
7
|
export function QuoteBlock({ quote, attribution, role }: QuoteBlockProps) {
|
|
8
8
|
return (
|
|
9
|
-
<div
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
<div
|
|
10
|
+
className={[
|
|
11
|
+
"relative px-8 py-6 my-6 bg-background-secondary rounded-lg",
|
|
12
|
+
"border border-border-subtle break-inside-avoid",
|
|
13
|
+
// Decorative opening curly-quote glyph rendered via the ::before
|
|
14
|
+
// pseudo-element. Kept as utility classes to avoid a CSS file rule.
|
|
15
|
+
"before:content-['\\201C'] before:absolute before:top-2 before:left-3",
|
|
16
|
+
"before:text-5xl before:text-primary/50 before:leading-none",
|
|
17
|
+
"before:font-[Georgia,serif]",
|
|
18
|
+
].join(" ")}
|
|
19
|
+
>
|
|
20
|
+
<p className="italic text-foreground text-[1.0625rem] leading-relaxed">{quote}</p>
|
|
21
|
+
<p className="mt-3 text-sm text-muted">
|
|
12
22
|
— {attribution}
|
|
13
23
|
{role && <span className="text-muted">, {role}</span>}
|
|
14
24
|
</p>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ArrowRight, Clock, Zap } from "lucide-react";
|
|
2
|
-
import type { Recommendation } from "@/lib/
|
|
2
|
+
import type { Recommendation } from "@/lib/types";
|
|
3
3
|
import { cn } from "@/lib/utils";
|
|
4
4
|
|
|
5
5
|
interface RecommendationCardProps {
|
|
@@ -30,11 +30,13 @@ export function RecommendationCard({ recommendation, index }: RecommendationCard
|
|
|
30
30
|
const Icon = config.icon;
|
|
31
31
|
|
|
32
32
|
return (
|
|
33
|
-
<div className="
|
|
34
|
-
<div className="
|
|
33
|
+
<div className="bg-background-secondary border border-border rounded-lg p-6 mb-4 break-inside-avoid">
|
|
34
|
+
<div className="flex justify-between items-start mb-3">
|
|
35
35
|
<div className="flex items-start gap-3">
|
|
36
36
|
<span className="text-primary font-bold font-sans text-lg">{index + 1}</span>
|
|
37
|
-
<span className="
|
|
37
|
+
<span className="font-heading font-semibold text-foreground text-base">
|
|
38
|
+
{recommendation.title}
|
|
39
|
+
</span>
|
|
38
40
|
</div>
|
|
39
41
|
<span
|
|
40
42
|
className={cn(
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Dimension, RubricLevel } from "@/lib/types";
|
|
2
|
+
|
|
3
|
+
// Default 5-step palette: red → orange → amber → lime → green.
|
|
4
|
+
// Indexed by position in the levels array (0..n-1). For an N-level rubric,
|
|
5
|
+
// we sample evenly across this palette.
|
|
6
|
+
const DEFAULT_PALETTE = [
|
|
7
|
+
"#dc2626", // red
|
|
8
|
+
"#ea580c", // orange
|
|
9
|
+
"#d97706", // amber
|
|
10
|
+
"#84cc16", // lime
|
|
11
|
+
"#16a34a", // green
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function colorForLevel(index: number, total: number, palette: string[]): string {
|
|
15
|
+
if (total <= 1) return palette[palette.length - 1];
|
|
16
|
+
const t = index / (total - 1); // 0..1
|
|
17
|
+
const pos = t * (palette.length - 1);
|
|
18
|
+
return palette[Math.round(pos)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Mix a hex color toward white by `amount` (0..1). 0.9 → soft tint.
|
|
22
|
+
function lighten(hex: string, amount: number): string {
|
|
23
|
+
const m = hex.match(/^#([0-9a-f]{6})$/i);
|
|
24
|
+
if (!m) return hex;
|
|
25
|
+
const n = parseInt(m[1], 16);
|
|
26
|
+
const r = (n >> 16) & 255;
|
|
27
|
+
const g = (n >> 8) & 255;
|
|
28
|
+
const b = n & 255;
|
|
29
|
+
const mix = (c: number) => Math.round(c + (255 - c) * amount);
|
|
30
|
+
const toHex = (c: number) => c.toString(16).padStart(2, "0");
|
|
31
|
+
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RubricTableProps {
|
|
35
|
+
dimension: Dimension;
|
|
36
|
+
/**
|
|
37
|
+
* Optional color palette (low → high) used to tint each level's row.
|
|
38
|
+
* Length is independent of the number of levels — entries are sampled.
|
|
39
|
+
* Defaults to a 5-step red → green ramp.
|
|
40
|
+
*/
|
|
41
|
+
palette?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function RubricTable({ dimension, palette = DEFAULT_PALETTE }: RubricTableProps) {
|
|
45
|
+
const levels: RubricLevel[] = dimension.levels;
|
|
46
|
+
// Sort ascending for palette indexing (low score = first color), then
|
|
47
|
+
// reverse for display so the highest score sits at the top of the table.
|
|
48
|
+
const ascending = [...levels].sort((a, b) => a.score - b.score);
|
|
49
|
+
const sorted = [...ascending].reverse();
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="my-5 break-inside-avoid">
|
|
53
|
+
<table className="w-full border-separate [border-spacing:0_0.3rem] font-body text-[0.85rem]">
|
|
54
|
+
<tbody>
|
|
55
|
+
{sorted.map((level) => {
|
|
56
|
+
const ascIndex = ascending.findIndex((l) => l.score === level.score);
|
|
57
|
+
const color = colorForLevel(ascIndex, ascending.length, palette);
|
|
58
|
+
const rowBg = lighten(color, 0.88);
|
|
59
|
+
const leftStyle = level.highlight
|
|
60
|
+
? {
|
|
61
|
+
backgroundColor: rowBg,
|
|
62
|
+
borderTop: `2px dashed ${color}`,
|
|
63
|
+
borderBottom: `2px dashed ${color}`,
|
|
64
|
+
borderLeft: `2px dashed ${color}`,
|
|
65
|
+
}
|
|
66
|
+
: { backgroundColor: rowBg };
|
|
67
|
+
const rightStyle = level.highlight
|
|
68
|
+
? {
|
|
69
|
+
backgroundColor: rowBg,
|
|
70
|
+
borderTop: `2px dashed ${color}`,
|
|
71
|
+
borderBottom: `2px dashed ${color}`,
|
|
72
|
+
borderRight: `2px dashed ${color}`,
|
|
73
|
+
}
|
|
74
|
+
: { backgroundColor: rowBg };
|
|
75
|
+
return (
|
|
76
|
+
<tr key={level.score}>
|
|
77
|
+
<td
|
|
78
|
+
className="w-[14%] align-middle text-center px-3 py-2.5 rounded-l-md"
|
|
79
|
+
style={leftStyle}
|
|
80
|
+
>
|
|
81
|
+
<div
|
|
82
|
+
className="font-sans text-2xl font-bold leading-none"
|
|
83
|
+
style={{ color }}
|
|
84
|
+
>
|
|
85
|
+
{level.score}
|
|
86
|
+
</div>
|
|
87
|
+
<div className="mt-1 font-sans text-[0.7rem] font-semibold uppercase tracking-wide text-foreground">
|
|
88
|
+
{level.label}
|
|
89
|
+
</div>
|
|
90
|
+
</td>
|
|
91
|
+
<td
|
|
92
|
+
className="align-middle px-4 py-2.5 rounded-r-md leading-snug text-[0.8125rem] text-foreground"
|
|
93
|
+
style={rightStyle}
|
|
94
|
+
>
|
|
95
|
+
{level.anchor}
|
|
96
|
+
</td>
|
|
97
|
+
</tr>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</tbody>
|
|
101
|
+
</table>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
// Default 5-step ramp (red → green). For score badges we use slightly more
|
|
4
|
+
// saturated background/border colors than RubricTable's underline ramp.
|
|
5
|
+
const DEFAULT_STEPS: { color: string; bg: string; border: string }[] = [
|
|
6
|
+
{ color: "#dc2626", bg: "rgba(220,38,38,0.12)", border: "rgba(220,38,38,0.40)" }, // 1
|
|
7
|
+
{ color: "#ea580c", bg: "rgba(234,88,12,0.12)", border: "rgba(234,88,12,0.35)" }, // 2
|
|
8
|
+
{ color: "#d97706", bg: "rgba(217,119,6,0.12)", border: "rgba(217,119,6,0.35)" }, // 3
|
|
9
|
+
{ color: "#4d7c0f", bg: "rgba(132,204,22,0.14)", border: "rgba(132,204,22,0.40)" }, // 4
|
|
10
|
+
{ color: "#16a34a", bg: "rgba(22,163,74,0.12)", border: "rgba(22,163,74,0.35)" }, // 5
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
interface ScoreBadgeProps {
|
|
14
|
+
score: number;
|
|
15
|
+
/**
|
|
16
|
+
* Maximum possible score on the rubric this badge sits on. Used to map
|
|
17
|
+
* the score onto the color ramp. Defaults to 5.
|
|
18
|
+
*/
|
|
19
|
+
maxScore?: number;
|
|
20
|
+
/** Optional palette override. Length-independent (entries are sampled). */
|
|
21
|
+
palette?: { color: string; bg: string; border: string }[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function step(score: number, maxScore: number, palette: typeof DEFAULT_STEPS) {
|
|
25
|
+
if (maxScore <= 1) return palette[palette.length - 1];
|
|
26
|
+
const clamped = Math.max(1, Math.min(score, maxScore));
|
|
27
|
+
const t = (clamped - 1) / (maxScore - 1);
|
|
28
|
+
const idx = Math.round(t * (palette.length - 1));
|
|
29
|
+
return palette[idx];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ScoreBadge({
|
|
33
|
+
score,
|
|
34
|
+
maxScore = 5,
|
|
35
|
+
palette = DEFAULT_STEPS,
|
|
36
|
+
}: ScoreBadgeProps) {
|
|
37
|
+
const s = step(score, maxScore, palette);
|
|
38
|
+
const style: CSSProperties = {
|
|
39
|
+
color: s.color,
|
|
40
|
+
background: s.bg,
|
|
41
|
+
borderColor: s.border,
|
|
42
|
+
};
|
|
43
|
+
return (
|
|
44
|
+
<span
|
|
45
|
+
className="inline-flex items-center justify-center min-w-7 h-7 px-2 rounded-md font-sans text-sm font-bold border border-transparent"
|
|
46
|
+
style={style}
|
|
47
|
+
>
|
|
48
|
+
{score}
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Scorecard as ScorecardType } from "@/lib/types";
|
|
2
|
+
import { BandBadge, type BandStyle } from "./band-badge";
|
|
3
|
+
import { ScoreBadge } from "./score-badge";
|
|
4
|
+
|
|
5
|
+
interface ScorecardProps {
|
|
6
|
+
scorecard: ScorecardType;
|
|
7
|
+
/**
|
|
8
|
+
* Optional band style override forwarded to BandBadge. Lets the project
|
|
9
|
+
* define its own band vocabulary (e.g., "Greenlit / Investigate / Decline").
|
|
10
|
+
*/
|
|
11
|
+
bandStyles?: Record<string, BandStyle>;
|
|
12
|
+
/**
|
|
13
|
+
* Maximum score on each dimension's rubric. Forwarded to ScoreBadge for
|
|
14
|
+
* color mapping. Defaults to 5.
|
|
15
|
+
*/
|
|
16
|
+
maxScore?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type GateVerdict = ScorecardType["gates"][number]["verdict"];
|
|
20
|
+
|
|
21
|
+
const gateColor: Record<GateVerdict, string> = {
|
|
22
|
+
pass: "text-success",
|
|
23
|
+
fail: "text-destructive",
|
|
24
|
+
flag: "text-warning",
|
|
25
|
+
"n/a": "text-muted",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function gateLabel(verdict: GateVerdict): string {
|
|
29
|
+
if (verdict === "pass") return "Pass";
|
|
30
|
+
if (verdict === "fail") return "Fail";
|
|
31
|
+
if (verdict === "flag") return "Flag";
|
|
32
|
+
return "N/A";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sectionLabel =
|
|
36
|
+
"font-sans text-[0.68rem] font-bold uppercase tracking-widest text-primary mb-2.5";
|
|
37
|
+
|
|
38
|
+
export function Scorecard({ scorecard, bandStyles, maxScore = 5 }: ScorecardProps) {
|
|
39
|
+
const maxTotal = scorecard.maxTotal ?? 20;
|
|
40
|
+
const cellBase = "px-3 py-2 border-b border-border-subtle align-top";
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="bg-background-secondary border border-border rounded-xl p-5 my-6 break-inside-avoid">
|
|
44
|
+
{/* Header */}
|
|
45
|
+
<div className="flex justify-between items-start gap-4 mb-3 pb-3 border-b border-border-subtle">
|
|
46
|
+
<div className="flex items-baseline gap-2">
|
|
47
|
+
<span className="font-sans text-xl font-bold text-primary">
|
|
48
|
+
{scorecard.index}.
|
|
49
|
+
</span>
|
|
50
|
+
<span className="font-heading text-lg font-semibold text-foreground">
|
|
51
|
+
{scorecard.name}
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
<BandBadge band={scorecard.band} bandStyles={bandStyles} />
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<p className="text-[0.95rem] text-foreground mb-4 leading-relaxed">
|
|
58
|
+
<span className="font-sans text-[0.7rem] font-bold uppercase tracking-widest text-muted">
|
|
59
|
+
Outcome:
|
|
60
|
+
</span>{" "}
|
|
61
|
+
{scorecard.outcome}
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
{/* Gates */}
|
|
65
|
+
<div className="my-4">
|
|
66
|
+
<div className={sectionLabel}>Pre-scoring gates</div>
|
|
67
|
+
<table className="w-full border-collapse text-[0.85rem] font-body">
|
|
68
|
+
<tbody>
|
|
69
|
+
{scorecard.gates.map((gate) => (
|
|
70
|
+
<tr key={gate.name}>
|
|
71
|
+
<td className={`${cellBase} font-sans font-semibold w-[32%]`}>
|
|
72
|
+
{gate.name}
|
|
73
|
+
</td>
|
|
74
|
+
<td
|
|
75
|
+
className={`${cellBase} w-[18%] font-sans text-[0.78rem] font-bold uppercase tracking-wide ${gateColor[gate.verdict]}`}
|
|
76
|
+
>
|
|
77
|
+
{gateLabel(gate.verdict)}
|
|
78
|
+
</td>
|
|
79
|
+
<td className={`${cellBase} text-muted text-[0.8125rem]`}>
|
|
80
|
+
{gate.note ?? ""}
|
|
81
|
+
</td>
|
|
82
|
+
</tr>
|
|
83
|
+
))}
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Scores */}
|
|
89
|
+
<div className="my-4">
|
|
90
|
+
<div className={sectionLabel}>Dimension scores</div>
|
|
91
|
+
<table className="w-full border-collapse text-[0.85rem] font-body">
|
|
92
|
+
<tbody>
|
|
93
|
+
{scorecard.scores.map((s) => (
|
|
94
|
+
<tr key={s.dimensionId}>
|
|
95
|
+
<td className={`${cellBase} font-sans font-semibold w-[22%]`}>
|
|
96
|
+
{s.dimensionLabel}
|
|
97
|
+
</td>
|
|
98
|
+
<td className={`${cellBase} w-[8%] text-center`}>
|
|
99
|
+
<ScoreBadge score={s.score} maxScore={maxScore} />
|
|
100
|
+
</td>
|
|
101
|
+
<td className={`${cellBase} text-foreground leading-snug`}>
|
|
102
|
+
{s.justification}
|
|
103
|
+
</td>
|
|
104
|
+
</tr>
|
|
105
|
+
))}
|
|
106
|
+
</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Total row */}
|
|
111
|
+
<div className="flex justify-between items-center gap-4 mt-4 mb-2 px-4 py-2.5 bg-background border border-border rounded-md">
|
|
112
|
+
<div className="font-sans text-[0.95rem]">
|
|
113
|
+
Total: <strong className="text-xl text-primary">{scorecard.total}</strong> /{" "}
|
|
114
|
+
{maxTotal}
|
|
115
|
+
</div>
|
|
116
|
+
{scorecard.vetoTriggered && (
|
|
117
|
+
<div className="font-sans font-bold text-xs uppercase tracking-wide text-destructive">
|
|
118
|
+
VETO TRIGGERED: {scorecard.vetoNote}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{scorecard.weightingNote && (
|
|
124
|
+
<div className="text-[0.85rem] italic text-muted mt-2 px-4 py-2.5 bg-background border-l-[3px] border-accent rounded-r-md">
|
|
125
|
+
<span className={sectionLabel}>Weighting note:</span> {scorecard.weightingNote}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Recommendation */}
|
|
130
|
+
<div className="mt-4 pt-3 border-t border-border-subtle">
|
|
131
|
+
<div className={sectionLabel}>Recommendation</div>
|
|
132
|
+
<p className="text-[0.9rem] leading-relaxed mt-1 mb-3">
|
|
133
|
+
{scorecard.recommendation}
|
|
134
|
+
</p>
|
|
135
|
+
<dl className="grid grid-cols-3 gap-x-6 gap-y-3 m-0 text-[0.8125rem]">
|
|
136
|
+
{(
|
|
137
|
+
[
|
|
138
|
+
["First action", scorecard.firstAction],
|
|
139
|
+
["Owner", scorecard.owner],
|
|
140
|
+
["Next review", scorecard.nextReview],
|
|
141
|
+
] as const
|
|
142
|
+
).map(([label, value]) => (
|
|
143
|
+
<div key={label}>
|
|
144
|
+
<dt className="font-sans text-[0.65rem] font-bold uppercase tracking-widest text-muted mb-1">
|
|
145
|
+
{label}
|
|
146
|
+
</dt>
|
|
147
|
+
<dd className="m-0 text-foreground">{value}</dd>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</dl>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -9,7 +9,22 @@ interface SectionProps {
|
|
|
9
9
|
|
|
10
10
|
export function Section({ id, title, children, className }: SectionProps) {
|
|
11
11
|
return (
|
|
12
|
-
<section
|
|
12
|
+
<section
|
|
13
|
+
id={id}
|
|
14
|
+
className={cn(
|
|
15
|
+
"mb-12",
|
|
16
|
+
// Each top-level Section starts on a new page in print/PDF output.
|
|
17
|
+
// Harmless for the first section (already at the top of a page after
|
|
18
|
+
// the TOC's break-after-page).
|
|
19
|
+
"break-before-page",
|
|
20
|
+
// Descendant heading styles — applies to any h2..h4 inside the
|
|
21
|
+
// section body without forcing callers to repeat utility classes.
|
|
22
|
+
"[&_h2]:font-heading [&_h2]:text-3xl [&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mb-6 [&_h2]:pb-2 [&_h2]:border-b-2 [&_h2]:border-primary",
|
|
23
|
+
"[&_h3]:font-heading [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-6 [&_h3]:mb-3",
|
|
24
|
+
"[&_h4]:font-heading [&_h4]:text-lg [&_h4]:font-semibold [&_h4]:text-foreground [&_h4]:mt-4 [&_h4]:mb-2",
|
|
25
|
+
className
|
|
26
|
+
)}
|
|
27
|
+
>
|
|
13
28
|
<h2>{title}</h2>
|
|
14
29
|
{children}
|
|
15
30
|
</section>
|
|
@@ -6,14 +6,29 @@ interface SeverityBadgeProps {
|
|
|
6
6
|
severity: Severity;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
const base =
|
|
10
|
+
"inline-flex items-center px-3 py-1 rounded-full font-sans text-xs font-semibold uppercase tracking-wider border";
|
|
11
|
+
|
|
9
12
|
const severityConfig: Record<Severity, { label: string; className: string }> = {
|
|
10
|
-
critical: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
critical: {
|
|
14
|
+
label: "Critical",
|
|
15
|
+
className: "bg-destructive/10 text-destructive border-destructive/30",
|
|
16
|
+
},
|
|
17
|
+
high: {
|
|
18
|
+
label: "High",
|
|
19
|
+
className: "bg-[#ea580c]/10 text-[#ea580c] border-[#ea580c]/30",
|
|
20
|
+
},
|
|
21
|
+
medium: {
|
|
22
|
+
label: "Medium",
|
|
23
|
+
className: "bg-warning/10 text-warning border-warning/30",
|
|
24
|
+
},
|
|
25
|
+
low: {
|
|
26
|
+
label: "Low",
|
|
27
|
+
className: "bg-success/10 text-success border-success/30",
|
|
28
|
+
},
|
|
14
29
|
};
|
|
15
30
|
|
|
16
31
|
export function SeverityBadge({ severity }: SeverityBadgeProps) {
|
|
17
32
|
const config = severityConfig[severity];
|
|
18
|
-
return <span className={cn(
|
|
33
|
+
return <span className={cn(base, config.className)}>{config.label}</span>;
|
|
19
34
|
}
|
|
@@ -11,14 +11,20 @@ interface StatGridProps {
|
|
|
11
11
|
export function StatGrid({ stats }: StatGridProps) {
|
|
12
12
|
return (
|
|
13
13
|
<div
|
|
14
|
-
className="
|
|
14
|
+
className="grid gap-6 my-6 p-6 bg-background-secondary rounded-lg border border-border break-inside-avoid"
|
|
15
15
|
style={{ gridTemplateColumns: `repeat(${stats.length}, minmax(0, 1fr))` }}
|
|
16
16
|
>
|
|
17
17
|
{stats.map((s) => (
|
|
18
|
-
<div key={s.label} className="
|
|
19
|
-
<div className="
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
<div key={s.label} className="text-left">
|
|
19
|
+
<div className="font-sans text-4xl font-bold text-primary -tracking-wider leading-none">
|
|
20
|
+
{s.value}
|
|
21
|
+
</div>
|
|
22
|
+
<div className="mt-2 font-sans text-[0.8125rem] font-semibold text-foreground">
|
|
23
|
+
{s.label}
|
|
24
|
+
</div>
|
|
25
|
+
{s.caption && (
|
|
26
|
+
<div className="mt-1 font-body text-[0.8125rem] text-muted">{s.caption}</div>
|
|
27
|
+
)}
|
|
22
28
|
</div>
|
|
23
29
|
))}
|
|
24
30
|
</div>
|
|
@@ -10,14 +10,24 @@ interface TableOfContentsProps {
|
|
|
10
10
|
|
|
11
11
|
export function TableOfContents({ items, title = "Contents" }: TableOfContentsProps) {
|
|
12
12
|
return (
|
|
13
|
-
<nav className="
|
|
14
|
-
<h2>
|
|
15
|
-
|
|
13
|
+
<nav className="break-after-page mb-12">
|
|
14
|
+
<h2 className="font-heading text-3xl font-semibold text-foreground mb-6 pb-2 border-b-2 border-primary">
|
|
15
|
+
{title}
|
|
16
|
+
</h2>
|
|
17
|
+
<ol className="list-none p-0 m-0">
|
|
16
18
|
{items.map((item, i) => (
|
|
17
|
-
<li
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
<li
|
|
20
|
+
key={item.id}
|
|
21
|
+
className="my-3 border-b border-dotted border-border-emphasis pb-2"
|
|
22
|
+
>
|
|
23
|
+
<a
|
|
24
|
+
href={`#${item.id}`}
|
|
25
|
+
className="flex gap-4 items-baseline text-foreground no-underline"
|
|
26
|
+
>
|
|
27
|
+
<span className="font-sans font-semibold text-sm text-primary w-8 shrink-0">
|
|
28
|
+
{(i + 1).toString().padStart(2, "0")}
|
|
29
|
+
</span>
|
|
30
|
+
<span className="font-heading font-medium text-base">{item.title}</span>
|
|
21
31
|
</a>
|
|
22
32
|
</li>
|
|
23
33
|
))}
|