@matt82198/aesop 0.1.0-beta.4 → 0.1.0-beta.5

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.
Files changed (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * MessagesTail — Role-colored recent main-thread messages with auto-follow toggle.
3
+ * Scrolls to bottom when new messages arrive (if follow enabled).
4
+ * User scroll up pauses auto-follow; toggle re-enables it.
5
+ */
6
+
7
+ import { useEffect, useRef, useState } from 'react';
8
+ import { formatTimestamp } from '../lib/format';
9
+ import { TESTIDS } from '../test/fixtures';
10
+ import type { Message } from '../lib/types';
11
+ import styles from './MessagesTail.module.css';
12
+
13
+ const FOLLOW_THRESHOLD = 50; // pixels from bottom
14
+ const MAX_MESSAGES = 12; // Display last ~12 messages
15
+
16
+ interface Props {
17
+ messages: Message[];
18
+ }
19
+
20
+ export default function MessagesTail({ messages }: Props) {
21
+ const containerRef = useRef<HTMLDivElement>(null);
22
+ const [isFollowing, setIsFollowing] = useState(true);
23
+
24
+ // Detect if user scrolled up manually
25
+ const handleScroll = () => {
26
+ if (!containerRef.current) return;
27
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
28
+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
29
+ if (distanceFromBottom > FOLLOW_THRESHOLD) {
30
+ setIsFollowing(false);
31
+ }
32
+ };
33
+
34
+ // Auto-scroll to bottom when new messages arrive (if following)
35
+ useEffect(() => {
36
+ if (!isFollowing || !containerRef.current) return;
37
+
38
+ // Wait for DOM to update
39
+ setTimeout(() => {
40
+ const lastMessage = containerRef.current?.lastElementChild as HTMLElement;
41
+ if (lastMessage?.scrollIntoView) {
42
+ lastMessage.scrollIntoView({ behavior: 'smooth' });
43
+ } else if (containerRef.current) {
44
+ // Fallback for test environment
45
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
46
+ }
47
+ }, 0);
48
+ }, [messages, isFollowing]);
49
+
50
+ const displayMessages = messages.slice(-MAX_MESSAGES);
51
+ const lastMessageTimestamp = displayMessages.length > 0 ? displayMessages[displayMessages.length - 1].timestamp : null;
52
+
53
+ return (
54
+ <div data-testid={TESTIDS.messagesTail} className={styles.container} aria-live="polite" aria-label="Main-thread messages">
55
+ <div className={styles.header}>
56
+ <h3 className={styles.title}>Main-Thread Messages</h3>
57
+ <button
58
+ type="button"
59
+ data-testid={TESTIDS.messagesFollowToggle}
60
+ aria-pressed={isFollowing}
61
+ onClick={() => setIsFollowing(!isFollowing)}
62
+ className={styles.toggleButton}
63
+ title={isFollowing ? 'Stop auto-following' : 'Resume auto-following'}
64
+ >
65
+ {isFollowing ? '📌 Following' : '📌 Paused'}
66
+ </button>
67
+ </div>
68
+
69
+ <div ref={containerRef} className={styles.messagesBox} onScroll={handleScroll}>
70
+ {displayMessages.length === 0 ? (
71
+ <div className={styles.emptyState}>(no messages)</div>
72
+ ) : (
73
+ displayMessages.map((msg, idx) => (
74
+ <div
75
+ key={idx}
76
+ data-testid={`message-${idx}`}
77
+ className={`${styles.message} ${styles[`role-${msg.role}`]}`}
78
+ >
79
+ <span className={styles.role}>{msg.role}</span>
80
+ <span className={styles.timestamp}>{formatTimestamp(msg.timestamp)}</span>
81
+ <div className={styles.text}>{msg.text}</div>
82
+ </div>
83
+ ))
84
+ )}
85
+ </div>
86
+
87
+ {lastMessageTimestamp && (
88
+ <div className={styles.footer}>
89
+ <span className={styles.footerTimestamp}>updated {formatTimestamp(lastMessageTimestamp)}</span>
90
+ </div>
91
+ )}
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,90 @@
1
+ .repos-panel {
2
+ flex: 1;
3
+ min-width: 0;
4
+ padding: var(--space-5);
5
+ background: var(--color-surface);
6
+ border: 1px solid var(--color-border);
7
+ border-radius: var(--radius-md);
8
+ }
9
+
10
+ .repos-panel h2 {
11
+ margin: 0 0 var(--space-4) 0;
12
+ font-size: var(--font-size-lg);
13
+ font-weight: var(--font-weight-bold);
14
+ color: var(--color-text);
15
+ }
16
+
17
+ .repos-panel__list {
18
+ list-style: none;
19
+ margin: 0;
20
+ padding: 0;
21
+ display: flex;
22
+ flex-direction: column;
23
+ gap: var(--space-3);
24
+ }
25
+
26
+ .repos-panel__item {
27
+ display: flex;
28
+ justify-content: space-between;
29
+ align-items: center;
30
+ gap: var(--space-3);
31
+ padding: var(--space-3);
32
+ background: var(--color-bg-subtle);
33
+ border: 1px solid var(--color-border-subtle);
34
+ border-radius: var(--radius-md);
35
+ font-size: var(--font-size-sm);
36
+ }
37
+
38
+ .repos-panel__item[data-severity='ok'] {
39
+ background: var(--color-status-ok-bg);
40
+ border-color: var(--color-status-ok);
41
+ }
42
+
43
+ .repos-panel__item[data-severity='warn'] {
44
+ background: var(--color-status-warn-bg);
45
+ border-color: var(--color-status-warn);
46
+ }
47
+
48
+ .repos-panel__item[data-severity='error'] {
49
+ background: var(--color-status-error-bg);
50
+ border-color: var(--color-status-error);
51
+ }
52
+
53
+ .repos-panel__name {
54
+ font-weight: var(--font-weight-bold);
55
+ color: var(--color-text);
56
+ font-family: var(--font-mono);
57
+ }
58
+
59
+ .repos-panel__state {
60
+ font-family: var(--font-mono);
61
+ font-size: var(--font-size-xs);
62
+ text-align: right;
63
+ }
64
+
65
+ .empty-state {
66
+ padding: var(--space-4);
67
+ text-align: center;
68
+ color: var(--color-text-muted);
69
+ font-size: var(--font-size-sm);
70
+ }
71
+
72
+ @media (max-width: 768px) {
73
+ .repos-panel {
74
+ padding: var(--space-3);
75
+ }
76
+
77
+ .repos-panel h2 {
78
+ font-size: var(--font-size-md);
79
+ }
80
+
81
+ .repos-panel__item {
82
+ padding: var(--space-2);
83
+ flex-direction: column;
84
+ align-items: flex-start;
85
+ }
86
+
87
+ .repos-panel__state {
88
+ text-align: left;
89
+ }
90
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { ReposPanel } from './ReposPanel';
4
+ import { fixtureRepos, TESTIDS } from '../test/fixtures';
5
+
6
+ describe('ReposPanel', () => {
7
+ it('renders repos list', () => {
8
+ render(<ReposPanel repos={fixtureRepos} />);
9
+
10
+ expect(screen.getByTestId(TESTIDS.reposPanel)).toBeInTheDocument();
11
+ expect(screen.getByText('aesop')).toBeInTheDocument();
12
+ expect(screen.getByText('tr-sample-tracker')).toBeInTheDocument();
13
+ });
14
+
15
+ it('renders repo status', () => {
16
+ render(<ReposPanel repos={fixtureRepos} />);
17
+
18
+ // Multiple repos have "clean" status, so use getAllByText
19
+ const cleanElements = screen.getAllByText('clean');
20
+ expect(cleanElements.length).toBeGreaterThan(0);
21
+ expect(screen.getByText(/dirty/)).toBeInTheDocument();
22
+ });
23
+
24
+ it('renders empty state when no repos', () => {
25
+ render(<ReposPanel repos={[]} />);
26
+
27
+ expect(screen.getByText('No repositories.')).toBeInTheDocument();
28
+ });
29
+
30
+ it('renders empty state when repos is null', () => {
31
+ render(<ReposPanel repos={null} />);
32
+
33
+ expect(screen.getByText('No repositories.')).toBeInTheDocument();
34
+ });
35
+
36
+ it('applies severity-based styling based on repo state', () => {
37
+ render(<ReposPanel repos={fixtureRepos} />);
38
+
39
+ const items = screen.getAllByRole('listitem');
40
+ // First repo is clean
41
+ expect(items[0]).toHaveAttribute('data-severity', 'ok');
42
+ // Second repo is dirty
43
+ expect(items[1]).toHaveAttribute('data-severity', 'warn');
44
+ });
45
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ReposPanel — Repository status display.
3
+ */
4
+
5
+ import type { RepoStatus } from '../lib/types';
6
+ import { TESTIDS } from '../test/fixtures';
7
+ import './ReposPanel.css';
8
+
9
+ interface ReposPanelProps {
10
+ repos: RepoStatus[] | null;
11
+ }
12
+
13
+ /**
14
+ * Determine repo state severity for color coding.
15
+ */
16
+ function getRepoSeverity(state: unknown): 'ok' | 'warn' | 'error' {
17
+ if (typeof state === 'string') {
18
+ if (state.toLowerCase().includes('clean')) return 'ok';
19
+ if (state.toLowerCase().includes('dirty')) return 'warn';
20
+ return 'error';
21
+ }
22
+ return 'ok';
23
+ }
24
+
25
+ export function ReposPanel({ repos }: ReposPanelProps) {
26
+ const hasRepos = repos && repos.length > 0;
27
+
28
+ return (
29
+ <section className="repos-panel" data-testid={TESTIDS.reposPanel}>
30
+ <h2>Repositories</h2>
31
+ {!hasRepos ? (
32
+ <p className="empty-state">No repositories.</p>
33
+ ) : (
34
+ <ul className="repos-panel__list">
35
+ {repos.map((repo, idx) => {
36
+ const repoName = repo.repo || `Repo ${idx + 1}`;
37
+ const repoState = repo.state ? String(repo.state) : 'unknown';
38
+ const severity = getRepoSeverity(repoState);
39
+
40
+ return (
41
+ <li
42
+ key={idx}
43
+ className="repos-panel__item"
44
+ data-severity={severity}
45
+ >
46
+ <span className="repos-panel__name">{repoName}</span>
47
+ <span
48
+ className="repos-panel__state"
49
+ style={{
50
+ color:
51
+ severity === 'ok'
52
+ ? 'var(--color-status-ok)'
53
+ : severity === 'warn'
54
+ ? 'var(--color-status-warn)'
55
+ : 'var(--color-status-error)',
56
+ }}
57
+ >
58
+ {repoState}
59
+ </span>
60
+ </li>
61
+ );
62
+ })}
63
+ </ul>
64
+ )}
65
+ </section>
66
+ );
67
+ }
@@ -0,0 +1,106 @@
1
+ /* Scorecard — accessible stat tiles for run verdicts */
2
+
3
+ .scorecard {
4
+ padding: var(--space-5);
5
+ border: 1px solid var(--color-border);
6
+ border-radius: var(--radius-md);
7
+ background: var(--color-surface);
8
+ }
9
+
10
+ .scorecard-header {
11
+ margin-bottom: var(--space-4);
12
+ }
13
+
14
+ .scorecard-header h3 {
15
+ margin: 0 0 var(--space-2) 0;
16
+ font-size: var(--font-size-lg);
17
+ font-weight: var(--font-weight-medium);
18
+ color: var(--color-text);
19
+ }
20
+
21
+ .scorecard-total {
22
+ font-size: var(--font-size-sm);
23
+ color: var(--color-text-muted);
24
+ }
25
+
26
+ .scorecard-tiles {
27
+ display: grid;
28
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
29
+ gap: var(--space-3);
30
+ margin-bottom: var(--space-4);
31
+ }
32
+
33
+ .scorecard-tile {
34
+ padding: var(--space-3);
35
+ border-radius: var(--radius-md);
36
+ text-align: center;
37
+ border: 1px solid var(--color-border);
38
+ background: var(--color-bg-subtle);
39
+ }
40
+
41
+ .tile-label {
42
+ font-size: var(--font-size-sm);
43
+ font-weight: var(--font-weight-medium);
44
+ margin-bottom: var(--space-1);
45
+ }
46
+
47
+ .tile-percent {
48
+ font-size: var(--font-size-xl);
49
+ font-weight: var(--font-weight-bold);
50
+ margin-bottom: var(--space-1);
51
+ }
52
+
53
+ .tile-count {
54
+ font-size: var(--font-size-xs);
55
+ color: var(--color-text-muted);
56
+ }
57
+
58
+ /* Severity coloring */
59
+ .scorecard-tile--ok {
60
+ border-color: var(--color-status-ok);
61
+ background: var(--color-status-ok-bg);
62
+ }
63
+
64
+ .scorecard-tile--ok .tile-label,
65
+ .scorecard-tile--ok .tile-percent {
66
+ color: var(--color-status-ok);
67
+ }
68
+
69
+ .scorecard-tile--error {
70
+ border-color: var(--color-status-error);
71
+ background: var(--color-status-error-bg);
72
+ }
73
+
74
+ .scorecard-tile--error .tile-label,
75
+ .scorecard-tile--error .tile-percent {
76
+ color: var(--color-status-error);
77
+ }
78
+
79
+ .scorecard-tile--warn {
80
+ border-color: var(--color-status-warn);
81
+ background: var(--color-status-warn-bg);
82
+ }
83
+
84
+ .scorecard-tile--warn .tile-label,
85
+ .scorecard-tile--warn .tile-percent {
86
+ color: var(--color-status-warn);
87
+ }
88
+
89
+ .scorecard-tile--neutral {
90
+ border-color: var(--color-status-neutral);
91
+ }
92
+
93
+ .scorecard-tile--neutral .tile-label,
94
+ .scorecard-tile--neutral .tile-percent {
95
+ color: var(--color-status-neutral);
96
+ }
97
+
98
+ .scorecard-footer {
99
+ border-top: 1px solid var(--color-border-subtle);
100
+ padding-top: var(--space-3);
101
+ }
102
+
103
+ .scorecard-footer small {
104
+ color: var(--color-text-muted);
105
+ font-size: var(--font-size-xs);
106
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Scorecard component tests — stat tiles for run verdicts.
3
+ * Tests both modes: with pricing and without. Tests empty data handling.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { render, screen } from '@testing-library/react';
8
+ import { Scorecard } from './Scorecard';
9
+ import { fixtureCost, fixtureCostWithPricing, TESTIDS } from '../test/fixtures';
10
+
11
+ describe('Scorecard', () => {
12
+ it('renders scorecard with fixture data', () => {
13
+ render(<Scorecard cost={fixtureCost} />);
14
+ expect(screen.getByTestId(TESTIDS.scorecard)).toBeInTheDocument();
15
+ });
16
+
17
+ it('displays total runs count', () => {
18
+ render(<Scorecard cost={fixtureCost} />);
19
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
20
+ expect(scorecard.textContent).toContain('142');
21
+ });
22
+
23
+ it('displays OK rate as percentage', () => {
24
+ render(<Scorecard cost={fixtureCost} />);
25
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
26
+ // ok_rate is 0.9296, should display as "92.96%"
27
+ expect(scorecard.textContent).toMatch(/92\.9\d%|93\.0%/);
28
+ });
29
+
30
+ it('displays FAILED rate', () => {
31
+ render(<Scorecard cost={fixtureCost} />);
32
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
33
+ // failed_rate is 0.0493, should display as "4.9%" or similar
34
+ expect(scorecard.textContent).toMatch(/4\.\d%|5\.0%/);
35
+ });
36
+
37
+ it('displays EMPTY rate', () => {
38
+ render(<Scorecard cost={fixtureCost} />);
39
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
40
+ // empty_rate is 0.0141, should display as "1.4%" or "1.5%"
41
+ expect(scorecard.textContent).toMatch(/1\.\d%|1\.4%/);
42
+ });
43
+
44
+ it('displays HUNG rate', () => {
45
+ render(<Scorecard cost={fixtureCost} />);
46
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
47
+ // hung_rate is 0.007, should display as "0.7%" or similar
48
+ expect(scorecard.textContent).toMatch(/0\.\d%/);
49
+ });
50
+
51
+ it('shows severity coloring for OK stat', () => {
52
+ render(<Scorecard cost={fixtureCost} />);
53
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
54
+ // Should have some indication of OK status (class or style)
55
+ expect(scorecard.innerHTML).toContain('OK');
56
+ });
57
+
58
+ it('shows severity coloring for FAILED stat', () => {
59
+ render(<Scorecard cost={fixtureCost} />);
60
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
61
+ expect(scorecard.innerHTML).toContain('FAILED');
62
+ });
63
+
64
+ it('shows skipped_lines footnote when > 0', () => {
65
+ render(<Scorecard cost={fixtureCost} />);
66
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
67
+ // skipped_lines is 3
68
+ expect(scorecard.textContent).toContain('3');
69
+ });
70
+
71
+ it('does not show skipped_lines footnote when 0', () => {
72
+ const noskip = {
73
+ ...fixtureCost,
74
+ skipped_lines: 0,
75
+ };
76
+ render(<Scorecard cost={noskip} />);
77
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
78
+ // Should not mention skipped lines
79
+ const text = scorecard.textContent || '';
80
+ // This is a loose check — just ensure no excessive mention
81
+ const skipMentions = (text.match(/skipped/gi) || []).length;
82
+ expect(skipMentions).toBeLessThanOrEqual(1); // tolerance for "skipped" in any case
83
+ });
84
+
85
+ it('works with pricing fixture data', () => {
86
+ render(<Scorecard cost={fixtureCostWithPricing} />);
87
+ expect(screen.getByTestId(TESTIDS.scorecard)).toBeInTheDocument();
88
+ });
89
+
90
+ it('handles empty scorecard (0 runs)', () => {
91
+ const empty = {
92
+ ...fixtureCost,
93
+ overall_scorecard: {
94
+ total_runs: 0,
95
+ ok_count: 0,
96
+ failed_count: 0,
97
+ empty_count: 0,
98
+ hung_count: 0,
99
+ ok_rate: 0,
100
+ failed_rate: 0,
101
+ empty_rate: 0,
102
+ hung_rate: 0,
103
+ },
104
+ };
105
+ render(<Scorecard cost={empty} />);
106
+ expect(screen.getByTestId(TESTIDS.scorecard)).toBeInTheDocument();
107
+ expect(screen.getByTestId(TESTIDS.scorecard).textContent).toContain('0');
108
+ });
109
+
110
+ it('uses theme color tokens for status colors', () => {
111
+ render(<Scorecard cost={fixtureCost} />);
112
+ const scorecard = screen.getByTestId(TESTIDS.scorecard);
113
+ // Verify that no inline hex colors are used (must use CSS vars)
114
+ const html = scorecard.innerHTML;
115
+ expect(html).not.toMatch(/#[0-9a-fA-F]{3,6}/);
116
+ });
117
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Scorecard component — accessible stat tiles for run verdicts.
3
+ * Displays OK/FAILED/EMPTY/HUNG rates as percentages and counts.
4
+ * Severity coloring uses theme color tokens.
5
+ * Includes skipped_lines footnote when > 0.
6
+ */
7
+
8
+ import type { CostSummary } from '../lib/types';
9
+ import { formatPercent } from '../lib/format';
10
+ import { TESTIDS } from '../test/fixtures';
11
+ import './Scorecard.css';
12
+
13
+ interface ScorecardProps {
14
+ cost: CostSummary;
15
+ }
16
+
17
+ interface StatTile {
18
+ label: string;
19
+ count: number;
20
+ rate: number;
21
+ severity: 'ok' | 'error' | 'warn' | 'neutral';
22
+ }
23
+
24
+ export function Scorecard({ cost }: ScorecardProps) {
25
+ const { overall_scorecard: sc, skipped_lines } = cost;
26
+
27
+ const stats: StatTile[] = [
28
+ {
29
+ label: 'OK',
30
+ count: sc.ok_count,
31
+ rate: sc.ok_rate,
32
+ severity: 'ok',
33
+ },
34
+ {
35
+ label: 'FAILED',
36
+ count: sc.failed_count,
37
+ rate: sc.failed_rate,
38
+ severity: 'error',
39
+ },
40
+ {
41
+ label: 'EMPTY',
42
+ count: sc.empty_count,
43
+ rate: sc.empty_rate,
44
+ severity: 'warn',
45
+ },
46
+ {
47
+ label: 'HUNG',
48
+ count: sc.hung_count,
49
+ rate: sc.hung_rate,
50
+ severity: 'error',
51
+ },
52
+ ];
53
+
54
+ return (
55
+ <section
56
+ className="scorecard"
57
+ data-testid={TESTIDS.scorecard}
58
+ aria-label="Fleet quality scorecard"
59
+ >
60
+ <header className="scorecard-header">
61
+ <h3>Run Verdict Rates</h3>
62
+ <div className="scorecard-total">Total runs: {sc.total_runs}</div>
63
+ </header>
64
+ <div className="scorecard-tiles">
65
+ {stats.map((stat) => (
66
+ <article
67
+ key={stat.label}
68
+ className={`scorecard-tile scorecard-tile--${stat.severity}`}
69
+ >
70
+ <div className="tile-label">{stat.label}</div>
71
+ <div className="tile-percent">{formatPercent(stat.rate)}</div>
72
+ <div className="tile-count">{stat.count} runs</div>
73
+ </article>
74
+ ))}
75
+ </div>
76
+ {skipped_lines > 0 && (
77
+ <footer className="scorecard-footer">
78
+ <small>
79
+ Data quality note: {skipped_lines} line(s) in the ledger could not be parsed.
80
+ </small>
81
+ </footer>
82
+ )}
83
+ </section>
84
+ );
85
+ }