@agile-vibe-coding/avc 0.1.0 → 0.2.3
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/README.md +2 -0
- package/cli/agent-loader.js +21 -0
- package/cli/agents/agent-selector.md +129 -0
- package/cli/agents/architecture-recommender.md +418 -0
- package/cli/agents/database-deep-dive.md +470 -0
- package/cli/agents/database-recommender.md +634 -0
- package/cli/agents/doc-distributor.md +176 -0
- package/cli/agents/documentation-updater.md +203 -0
- package/cli/agents/epic-story-decomposer.md +280 -0
- package/cli/agents/feature-context-generator.md +91 -0
- package/cli/agents/gap-checker-epic.md +52 -0
- package/cli/agents/impact-checker-story.md +51 -0
- package/cli/agents/migration-guide-generator.md +305 -0
- package/cli/agents/mission-scope-generator.md +79 -0
- package/cli/agents/mission-scope-validator.md +112 -0
- package/cli/agents/project-context-extractor.md +107 -0
- package/cli/agents/project-documentation-creator.json +226 -0
- package/cli/agents/project-documentation-creator.md +595 -0
- package/cli/agents/question-prefiller.md +269 -0
- package/cli/agents/refiner-epic.md +39 -0
- package/cli/agents/refiner-story.md +42 -0
- package/cli/agents/solver-epic-api.json +15 -0
- package/cli/agents/solver-epic-api.md +39 -0
- package/cli/agents/solver-epic-backend.json +15 -0
- package/cli/agents/solver-epic-backend.md +39 -0
- package/cli/agents/solver-epic-cloud.json +15 -0
- package/cli/agents/solver-epic-cloud.md +39 -0
- package/cli/agents/solver-epic-data.json +15 -0
- package/cli/agents/solver-epic-data.md +39 -0
- package/cli/agents/solver-epic-database.json +15 -0
- package/cli/agents/solver-epic-database.md +39 -0
- package/cli/agents/solver-epic-developer.json +15 -0
- package/cli/agents/solver-epic-developer.md +39 -0
- package/cli/agents/solver-epic-devops.json +15 -0
- package/cli/agents/solver-epic-devops.md +39 -0
- package/cli/agents/solver-epic-frontend.json +15 -0
- package/cli/agents/solver-epic-frontend.md +39 -0
- package/cli/agents/solver-epic-mobile.json +15 -0
- package/cli/agents/solver-epic-mobile.md +39 -0
- package/cli/agents/solver-epic-qa.json +15 -0
- package/cli/agents/solver-epic-qa.md +39 -0
- package/cli/agents/solver-epic-security.json +15 -0
- package/cli/agents/solver-epic-security.md +39 -0
- package/cli/agents/solver-epic-solution-architect.json +15 -0
- package/cli/agents/solver-epic-solution-architect.md +39 -0
- package/cli/agents/solver-epic-test-architect.json +15 -0
- package/cli/agents/solver-epic-test-architect.md +39 -0
- package/cli/agents/solver-epic-ui.json +15 -0
- package/cli/agents/solver-epic-ui.md +39 -0
- package/cli/agents/solver-epic-ux.json +15 -0
- package/cli/agents/solver-epic-ux.md +39 -0
- package/cli/agents/solver-story-api.json +15 -0
- package/cli/agents/solver-story-api.md +39 -0
- package/cli/agents/solver-story-backend.json +15 -0
- package/cli/agents/solver-story-backend.md +39 -0
- package/cli/agents/solver-story-cloud.json +15 -0
- package/cli/agents/solver-story-cloud.md +39 -0
- package/cli/agents/solver-story-data.json +15 -0
- package/cli/agents/solver-story-data.md +39 -0
- package/cli/agents/solver-story-database.json +15 -0
- package/cli/agents/solver-story-database.md +39 -0
- package/cli/agents/solver-story-developer.json +15 -0
- package/cli/agents/solver-story-developer.md +39 -0
- package/cli/agents/solver-story-devops.json +15 -0
- package/cli/agents/solver-story-devops.md +39 -0
- package/cli/agents/solver-story-frontend.json +15 -0
- package/cli/agents/solver-story-frontend.md +39 -0
- package/cli/agents/solver-story-mobile.json +15 -0
- package/cli/agents/solver-story-mobile.md +39 -0
- package/cli/agents/solver-story-qa.json +15 -0
- package/cli/agents/solver-story-qa.md +39 -0
- package/cli/agents/solver-story-security.json +15 -0
- package/cli/agents/solver-story-security.md +39 -0
- package/cli/agents/solver-story-solution-architect.json +15 -0
- package/cli/agents/solver-story-solution-architect.md +39 -0
- package/cli/agents/solver-story-test-architect.json +15 -0
- package/cli/agents/solver-story-test-architect.md +39 -0
- package/cli/agents/solver-story-ui.json +15 -0
- package/cli/agents/solver-story-ui.md +39 -0
- package/cli/agents/solver-story-ux.json +15 -0
- package/cli/agents/solver-story-ux.md +39 -0
- package/cli/agents/story-doc-enricher.md +133 -0
- package/cli/agents/suggestion-business-analyst.md +88 -0
- package/cli/agents/suggestion-deployment-architect.md +263 -0
- package/cli/agents/suggestion-product-manager.md +129 -0
- package/cli/agents/suggestion-security-specialist.md +156 -0
- package/cli/agents/suggestion-technical-architect.md +269 -0
- package/cli/agents/suggestion-ux-researcher.md +93 -0
- package/cli/agents/task-subtask-decomposer.md +188 -0
- package/cli/agents/validator-documentation.json +152 -0
- package/cli/agents/validator-documentation.md +453 -0
- package/cli/agents/validator-epic-api.json +93 -0
- package/cli/agents/validator-epic-api.md +137 -0
- package/cli/agents/validator-epic-backend.json +93 -0
- package/cli/agents/validator-epic-backend.md +130 -0
- package/cli/agents/validator-epic-cloud.json +93 -0
- package/cli/agents/validator-epic-cloud.md +137 -0
- package/cli/agents/validator-epic-data.json +93 -0
- package/cli/agents/validator-epic-data.md +130 -0
- package/cli/agents/validator-epic-database.json +93 -0
- package/cli/agents/validator-epic-database.md +137 -0
- package/cli/agents/validator-epic-developer.json +74 -0
- package/cli/agents/validator-epic-developer.md +153 -0
- package/cli/agents/validator-epic-devops.json +74 -0
- package/cli/agents/validator-epic-devops.md +153 -0
- package/cli/agents/validator-epic-frontend.json +74 -0
- package/cli/agents/validator-epic-frontend.md +153 -0
- package/cli/agents/validator-epic-mobile.json +93 -0
- package/cli/agents/validator-epic-mobile.md +130 -0
- package/cli/agents/validator-epic-qa.json +93 -0
- package/cli/agents/validator-epic-qa.md +130 -0
- package/cli/agents/validator-epic-security.json +74 -0
- package/cli/agents/validator-epic-security.md +154 -0
- package/cli/agents/validator-epic-solution-architect.json +74 -0
- package/cli/agents/validator-epic-solution-architect.md +156 -0
- package/cli/agents/validator-epic-test-architect.json +93 -0
- package/cli/agents/validator-epic-test-architect.md +130 -0
- package/cli/agents/validator-epic-ui.json +93 -0
- package/cli/agents/validator-epic-ui.md +130 -0
- package/cli/agents/validator-epic-ux.json +93 -0
- package/cli/agents/validator-epic-ux.md +130 -0
- package/cli/agents/validator-selector.md +211 -0
- package/cli/agents/validator-story-api.json +104 -0
- package/cli/agents/validator-story-api.md +152 -0
- package/cli/agents/validator-story-backend.json +104 -0
- package/cli/agents/validator-story-backend.md +152 -0
- package/cli/agents/validator-story-cloud.json +104 -0
- package/cli/agents/validator-story-cloud.md +152 -0
- package/cli/agents/validator-story-data.json +104 -0
- package/cli/agents/validator-story-data.md +152 -0
- package/cli/agents/validator-story-database.json +104 -0
- package/cli/agents/validator-story-database.md +152 -0
- package/cli/agents/validator-story-developer.json +104 -0
- package/cli/agents/validator-story-developer.md +152 -0
- package/cli/agents/validator-story-devops.json +104 -0
- package/cli/agents/validator-story-devops.md +152 -0
- package/cli/agents/validator-story-frontend.json +104 -0
- package/cli/agents/validator-story-frontend.md +152 -0
- package/cli/agents/validator-story-mobile.json +104 -0
- package/cli/agents/validator-story-mobile.md +152 -0
- package/cli/agents/validator-story-qa.json +104 -0
- package/cli/agents/validator-story-qa.md +152 -0
- package/cli/agents/validator-story-security.json +104 -0
- package/cli/agents/validator-story-security.md +152 -0
- package/cli/agents/validator-story-solution-architect.json +104 -0
- package/cli/agents/validator-story-solution-architect.md +152 -0
- package/cli/agents/validator-story-test-architect.json +104 -0
- package/cli/agents/validator-story-test-architect.md +152 -0
- package/cli/agents/validator-story-ui.json +104 -0
- package/cli/agents/validator-story-ui.md +152 -0
- package/cli/agents/validator-story-ux.json +104 -0
- package/cli/agents/validator-story-ux.md +152 -0
- package/cli/ansi-colors.js +21 -0
- package/cli/build-docs.js +298 -0
- package/cli/ceremony-history.js +369 -0
- package/cli/command-logger.js +245 -0
- package/cli/components/static-output.js +63 -0
- package/cli/console-output-manager.js +94 -0
- package/cli/docs-sync.js +306 -0
- package/cli/epic-story-validator.js +1174 -0
- package/cli/evaluation-prompts.js +1008 -0
- package/cli/execution-context.js +195 -0
- package/cli/generate-summary-table.js +340 -0
- package/cli/index.js +3 -25
- package/cli/init-model-config.js +697 -0
- package/cli/init.js +1765 -100
- package/cli/kanban-server-manager.js +228 -0
- package/cli/llm-claude.js +109 -0
- package/cli/llm-gemini.js +115 -0
- package/cli/llm-mock.js +233 -0
- package/cli/llm-openai.js +233 -0
- package/cli/llm-provider.js +300 -0
- package/cli/llm-token-limits.js +102 -0
- package/cli/llm-verifier.js +454 -0
- package/cli/logger.js +32 -5
- package/cli/message-constants.js +58 -0
- package/cli/message-manager.js +334 -0
- package/cli/message-types.js +96 -0
- package/cli/messaging-api.js +297 -0
- package/cli/model-pricing.js +169 -0
- package/cli/model-query-engine.js +468 -0
- package/cli/model-recommendation-analyzer.js +495 -0
- package/cli/model-selector.js +269 -0
- package/cli/output-buffer.js +107 -0
- package/cli/process-manager.js +332 -0
- package/cli/repl-ink.js +5840 -504
- package/cli/repl-old.js +4 -4
- package/cli/seed-processor.js +792 -0
- package/cli/sprint-planning-processor.js +1813 -0
- package/cli/template-processor.js +2306 -108
- package/cli/templates/project.md +25 -8
- package/cli/templates/vitepress-config.mts.template +34 -0
- package/cli/token-tracker.js +520 -0
- package/cli/tools/generate-story-validators.js +317 -0
- package/cli/tools/generate-validators.js +669 -0
- package/cli/update-checker.js +19 -17
- package/cli/update-notifier.js +4 -4
- package/cli/validation-router.js +605 -0
- package/cli/verification-tracker.js +563 -0
- package/kanban/README.md +386 -0
- package/kanban/client/README.md +205 -0
- package/kanban/client/components.json +20 -0
- package/kanban/client/dist/assets/index-CiD8PS2e.js +306 -0
- package/kanban/client/dist/assets/index-nLh0m82Q.css +1 -0
- package/kanban/client/dist/index.html +16 -0
- package/kanban/client/dist/vite.svg +1 -0
- package/kanban/client/index.html +15 -0
- package/kanban/client/package-lock.json +9442 -0
- package/kanban/client/package.json +44 -0
- package/kanban/client/postcss.config.js +6 -0
- package/kanban/client/public/vite.svg +1 -0
- package/kanban/client/src/App.jsx +622 -0
- package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
- package/kanban/client/src/components/ceremony/AskArchPopup.jsx +416 -0
- package/kanban/client/src/components/ceremony/AskModelPopup.jsx +616 -0
- package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +946 -0
- package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
- package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +619 -0
- package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +704 -0
- package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
- package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +154 -0
- package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
- package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
- package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
- package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +125 -0
- package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +228 -0
- package/kanban/client/src/components/kanban/CardDetailModal.jsx +559 -0
- package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
- package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
- package/kanban/client/src/components/kanban/GroupingSelector.jsx +57 -0
- package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
- package/kanban/client/src/components/kanban/KanbanCard.jsx +138 -0
- package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
- package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +789 -0
- package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
- package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
- package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
- package/kanban/client/src/components/settings/AgentsTab.jsx +353 -0
- package/kanban/client/src/components/settings/ApiKeysTab.jsx +113 -0
- package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +98 -0
- package/kanban/client/src/components/settings/CostThresholdsTab.jsx +94 -0
- package/kanban/client/src/components/settings/ModelPricingTab.jsx +204 -0
- package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
- package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
- package/kanban/client/src/components/stats/CostModal.jsx +353 -0
- package/kanban/client/src/components/ui/badge.jsx +27 -0
- package/kanban/client/src/components/ui/dialog.jsx +121 -0
- package/kanban/client/src/components/ui/tabs.jsx +85 -0
- package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
- package/kanban/client/src/hooks/useGrouping.js +118 -0
- package/kanban/client/src/hooks/useWebSocket.js +120 -0
- package/kanban/client/src/lib/__tests__/api.test.js +196 -0
- package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
- package/kanban/client/src/lib/api.js +401 -0
- package/kanban/client/src/lib/status-grouping.js +144 -0
- package/kanban/client/src/lib/utils.js +11 -0
- package/kanban/client/src/main.jsx +10 -0
- package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
- package/kanban/client/src/store/ceremonyStore.js +172 -0
- package/kanban/client/src/store/filterStore.js +201 -0
- package/kanban/client/src/store/kanbanStore.js +115 -0
- package/kanban/client/src/store/processStore.js +65 -0
- package/kanban/client/src/store/sprintPlanningStore.js +33 -0
- package/kanban/client/src/styles/globals.css +59 -0
- package/kanban/client/tailwind.config.js +77 -0
- package/kanban/client/vite.config.js +28 -0
- package/kanban/client/vitest.config.js +28 -0
- package/kanban/dev-start.sh +47 -0
- package/kanban/package.json +12 -0
- package/kanban/server/index.js +516 -0
- package/kanban/server/routes/ceremony.js +305 -0
- package/kanban/server/routes/costs.js +157 -0
- package/kanban/server/routes/processes.js +50 -0
- package/kanban/server/routes/settings.js +303 -0
- package/kanban/server/routes/websocket.js +276 -0
- package/kanban/server/routes/work-items.js +347 -0
- package/kanban/server/services/CeremonyService.js +1190 -0
- package/kanban/server/services/FileSystemScanner.js +95 -0
- package/kanban/server/services/FileWatcher.js +144 -0
- package/kanban/server/services/HierarchyBuilder.js +196 -0
- package/kanban/server/services/ProcessRegistry.js +122 -0
- package/kanban/server/services/WorkItemReader.js +123 -0
- package/kanban/server/services/WorkItemRefineService.js +510 -0
- package/kanban/server/start.js +49 -0
- package/kanban/server/utils/kanban-logger.js +132 -0
- package/kanban/server/utils/markdown.js +91 -0
- package/kanban/server/utils/status-grouping.js +107 -0
- package/kanban/server/workers/sponsor-call-worker.js +84 -0
- package/kanban/server/workers/sprint-planning-worker.js +130 -0
- package/package.json +34 -7
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { X, BarChart2, DollarSign, ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
BarChart,
|
|
5
|
+
Bar,
|
|
6
|
+
XAxis,
|
|
7
|
+
YAxis,
|
|
8
|
+
Tooltip,
|
|
9
|
+
ResponsiveContainer,
|
|
10
|
+
} from 'recharts';
|
|
11
|
+
import { getCostHistory } from '../../lib/api';
|
|
12
|
+
|
|
13
|
+
const RANGE_TABS = [
|
|
14
|
+
{ label: 'Today', value: 'today' },
|
|
15
|
+
{ label: '7 days', value: 7 },
|
|
16
|
+
{ label: '30 days', value: 30 },
|
|
17
|
+
{ label: '90 days', value: 90 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function formatCostLabel(cost) {
|
|
21
|
+
if (cost === 0) return '$0.00';
|
|
22
|
+
if (cost < 0.01) return '< $0.01';
|
|
23
|
+
return `$${cost.toFixed(2)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatCostDetail(cost) {
|
|
27
|
+
if (cost === 0) return '$0.0000';
|
|
28
|
+
return `$${cost.toFixed(4)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatTokens(n) {
|
|
32
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
33
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
34
|
+
return String(n);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatDateLabel(dateStr) {
|
|
38
|
+
const d = new Date(dateStr + 'T00:00:00');
|
|
39
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatCeremonyName(name) {
|
|
43
|
+
return name
|
|
44
|
+
.replace(/-/g, ' ')
|
|
45
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Strip parent prefix from stage name, e.g. "sponsor-call-validation" → "Validation" */
|
|
49
|
+
function formatStageName(name, parentName) {
|
|
50
|
+
if (name === parentName) return 'Documentation';
|
|
51
|
+
const prefix = `${parentName}-`;
|
|
52
|
+
if (name.startsWith(prefix)) return formatCeremonyName(name.slice(prefix.length));
|
|
53
|
+
return formatCeremonyName(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function CostModal({ onClose }) {
|
|
57
|
+
const [rangeMode, setRangeMode] = useState('today');
|
|
58
|
+
const [customFrom, setCustomFrom] = useState('');
|
|
59
|
+
const [customTo, setCustomTo] = useState('');
|
|
60
|
+
const [data, setData] = useState(null);
|
|
61
|
+
const [loading, setLoading] = useState(true);
|
|
62
|
+
const [expanded, setExpanded] = useState({});
|
|
63
|
+
|
|
64
|
+
// Fetch data when range changes
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setLoading(true);
|
|
67
|
+
setData(null);
|
|
68
|
+
|
|
69
|
+
let rangeArg;
|
|
70
|
+
if (rangeMode === 'today') {
|
|
71
|
+
const today = new Date().toISOString().split('T')[0];
|
|
72
|
+
rangeArg = { from: today, to: today };
|
|
73
|
+
} else if (rangeMode === 'custom') {
|
|
74
|
+
if (!customFrom || !customTo) {
|
|
75
|
+
setLoading(false);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
rangeArg = { from: customFrom, to: customTo };
|
|
79
|
+
} else {
|
|
80
|
+
rangeArg = parseInt(rangeMode, 10);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getCostHistory(rangeArg)
|
|
84
|
+
.then((d) => {
|
|
85
|
+
setData(d);
|
|
86
|
+
setLoading(false);
|
|
87
|
+
// Auto-expand parents that have stages
|
|
88
|
+
const init = {};
|
|
89
|
+
(d.ceremonies || []).forEach((c) => {
|
|
90
|
+
if (c.stages && c.stages.length > 0) init[c.name] = true;
|
|
91
|
+
});
|
|
92
|
+
setExpanded(init);
|
|
93
|
+
})
|
|
94
|
+
.catch(() => { setData({ daily: [], ceremonies: [] }); setLoading(false); });
|
|
95
|
+
}, [rangeMode, customFrom, customTo]);
|
|
96
|
+
|
|
97
|
+
// Close on Escape
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
|
100
|
+
document.addEventListener('keydown', handler);
|
|
101
|
+
return () => document.removeEventListener('keydown', handler);
|
|
102
|
+
}, [onClose]);
|
|
103
|
+
|
|
104
|
+
const toggleExpanded = (name) => setExpanded((prev) => ({ ...prev, [name]: !prev[name] }));
|
|
105
|
+
|
|
106
|
+
// Totals come from parent nodes only — stages are already rolled up into them
|
|
107
|
+
const totalCost = data?.ceremonies.reduce((s, c) => s + c.cost, 0) ?? 0;
|
|
108
|
+
const totalTokens = data?.ceremonies.reduce((s, c) => s + c.tokens, 0) ?? 0;
|
|
109
|
+
const totalCalls = data?.ceremonies.reduce((s, c) => s + c.calls, 0) ?? 0;
|
|
110
|
+
const hasData = data && (data.daily.length > 0 || data.ceremonies.length > 0);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="fixed inset-0 z-[65] flex items-center justify-center p-4">
|
|
114
|
+
{/* Backdrop */}
|
|
115
|
+
<div
|
|
116
|
+
className="absolute inset-0 bg-black/40"
|
|
117
|
+
onClick={onClose}
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
{/* Panel */}
|
|
122
|
+
<div
|
|
123
|
+
className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col"
|
|
124
|
+
style={{ height: '90vh', maxHeight: '900px' }}
|
|
125
|
+
onClick={(e) => e.stopPropagation()}
|
|
126
|
+
>
|
|
127
|
+
{/* Header */}
|
|
128
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 flex-shrink-0">
|
|
129
|
+
<div className="flex items-center gap-2">
|
|
130
|
+
<DollarSign className="w-5 h-5 text-slate-500" />
|
|
131
|
+
<h2 className="text-lg font-semibold text-slate-900">LLM Cost Tracker</h2>
|
|
132
|
+
</div>
|
|
133
|
+
<button
|
|
134
|
+
onClick={onClose}
|
|
135
|
+
className="text-slate-400 hover:text-slate-600 transition-colors"
|
|
136
|
+
aria-label="Close"
|
|
137
|
+
>
|
|
138
|
+
<X className="w-5 h-5" />
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{/* Scrollable body */}
|
|
143
|
+
<div className="overflow-y-auto flex-1 px-6 py-4 flex flex-col gap-5">
|
|
144
|
+
{/* Time range tabs — always 2 rows */}
|
|
145
|
+
<div className="flex flex-col gap-1.5">
|
|
146
|
+
{/* Row 1: preset buttons + Custom */}
|
|
147
|
+
<div className="flex items-center gap-2">
|
|
148
|
+
{RANGE_TABS.map((tab) => (
|
|
149
|
+
<button
|
|
150
|
+
key={tab.value}
|
|
151
|
+
onClick={() => setRangeMode(String(tab.value))}
|
|
152
|
+
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
|
153
|
+
rangeMode === String(tab.value)
|
|
154
|
+
? 'bg-blue-600 text-white'
|
|
155
|
+
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
156
|
+
}`}
|
|
157
|
+
>
|
|
158
|
+
{tab.label}
|
|
159
|
+
</button>
|
|
160
|
+
))}
|
|
161
|
+
<button
|
|
162
|
+
onClick={() => setRangeMode('custom')}
|
|
163
|
+
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
|
164
|
+
rangeMode === 'custom'
|
|
165
|
+
? 'bg-blue-600 text-white'
|
|
166
|
+
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
167
|
+
}`}
|
|
168
|
+
>
|
|
169
|
+
Custom
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{/* Row 2: date inputs — always rendered to keep height constant */}
|
|
174
|
+
<div className={`flex items-center gap-2 ${rangeMode !== 'custom' ? 'invisible' : ''}`}>
|
|
175
|
+
<input
|
|
176
|
+
type="date"
|
|
177
|
+
value={customFrom}
|
|
178
|
+
onChange={(e) => setCustomFrom(e.target.value)}
|
|
179
|
+
className="text-sm border border-slate-300 rounded px-2 py-0.5 text-slate-700"
|
|
180
|
+
/>
|
|
181
|
+
<span className="text-slate-400 text-sm">to</span>
|
|
182
|
+
<input
|
|
183
|
+
type="date"
|
|
184
|
+
value={customTo}
|
|
185
|
+
onChange={(e) => setCustomTo(e.target.value)}
|
|
186
|
+
className="text-sm border border-slate-300 rounded px-2 py-0.5 text-slate-700"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* Loading */}
|
|
192
|
+
{loading && (
|
|
193
|
+
<div className="flex-1 flex items-center justify-center">
|
|
194
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{/* No data */}
|
|
199
|
+
{!loading && !hasData && (
|
|
200
|
+
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 gap-3">
|
|
201
|
+
<BarChart2 className="w-10 h-10" />
|
|
202
|
+
<p className="text-sm">No usage data for this period.</p>
|
|
203
|
+
<p className="text-xs text-slate-300">Run a ceremony to start tracking costs.</p>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Content */}
|
|
208
|
+
{!loading && hasData && (
|
|
209
|
+
<>
|
|
210
|
+
{/* Stat chips */}
|
|
211
|
+
<div className="grid grid-cols-3 gap-3">
|
|
212
|
+
<div className="bg-slate-50 rounded-lg p-3">
|
|
213
|
+
<p className="text-xs text-slate-500 mb-1">Total Cost</p>
|
|
214
|
+
<p className="text-xl font-bold text-slate-900">{formatCostLabel(totalCost)}</p>
|
|
215
|
+
<p className="text-xs text-slate-400 mt-0.5">this period</p>
|
|
216
|
+
</div>
|
|
217
|
+
<div className="bg-slate-50 rounded-lg p-3">
|
|
218
|
+
<p className="text-xs text-slate-500 mb-1">Total Tokens</p>
|
|
219
|
+
<p className="text-xl font-bold text-slate-900">{formatTokens(totalTokens)}</p>
|
|
220
|
+
<p className="text-xs text-slate-400 mt-0.5">this period</p>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="bg-slate-50 rounded-lg p-3">
|
|
223
|
+
<p className="text-xs text-slate-500 mb-1">API Calls</p>
|
|
224
|
+
<p className="text-xl font-bold text-slate-900">{totalCalls.toLocaleString()}</p>
|
|
225
|
+
<p className="text-xs text-slate-400 mt-0.5">this period</p>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Bar chart */}
|
|
230
|
+
{data.daily.length > 0 && (
|
|
231
|
+
<div>
|
|
232
|
+
<p className="text-xs font-medium text-slate-500 mb-2 uppercase tracking-wide">Daily Cost</p>
|
|
233
|
+
<ResponsiveContainer width="100%" height={180}>
|
|
234
|
+
<BarChart data={data.daily} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
|
235
|
+
<XAxis
|
|
236
|
+
dataKey="date"
|
|
237
|
+
tickFormatter={formatDateLabel}
|
|
238
|
+
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
|
239
|
+
axisLine={false}
|
|
240
|
+
tickLine={false}
|
|
241
|
+
/>
|
|
242
|
+
<YAxis
|
|
243
|
+
tickFormatter={(v) => `$${v.toFixed(2)}`}
|
|
244
|
+
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
|
245
|
+
width={56}
|
|
246
|
+
axisLine={false}
|
|
247
|
+
tickLine={false}
|
|
248
|
+
/>
|
|
249
|
+
<Tooltip
|
|
250
|
+
formatter={(v) => [`$${v.toFixed(4)}`, 'Cost']}
|
|
251
|
+
labelFormatter={(label) => formatDateLabel(label)}
|
|
252
|
+
contentStyle={{ fontSize: 12 }}
|
|
253
|
+
/>
|
|
254
|
+
<Bar dataKey="cost" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
|
255
|
+
</BarChart>
|
|
256
|
+
</ResponsiveContainer>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{/* Ceremony breakdown — hierarchical */}
|
|
261
|
+
{data.ceremonies.length > 0 && (
|
|
262
|
+
<div>
|
|
263
|
+
<p className="text-xs font-medium text-slate-500 mb-2 uppercase tracking-wide">By Ceremony</p>
|
|
264
|
+
<div className="overflow-x-auto">
|
|
265
|
+
<table className="w-full text-sm">
|
|
266
|
+
<thead>
|
|
267
|
+
<tr className="text-left text-xs text-slate-400 border-b border-slate-100">
|
|
268
|
+
<th className="pb-2 font-medium">Ceremony / Stage</th>
|
|
269
|
+
<th className="pb-2 font-medium text-right">Calls</th>
|
|
270
|
+
<th className="pb-2 font-medium text-right">Tokens</th>
|
|
271
|
+
<th className="pb-2 font-medium text-right">Cost</th>
|
|
272
|
+
<th className="pb-2 font-medium pl-4">Share</th>
|
|
273
|
+
</tr>
|
|
274
|
+
</thead>
|
|
275
|
+
<tbody>
|
|
276
|
+
{data.ceremonies.map((c) => {
|
|
277
|
+
const pct = totalCost > 0 ? (c.cost / totalCost) * 100 : 0;
|
|
278
|
+
const hasStages = c.stages && c.stages.length > 0;
|
|
279
|
+
const isOpen = expanded[c.name];
|
|
280
|
+
|
|
281
|
+
return [
|
|
282
|
+
/* Parent row */
|
|
283
|
+
<tr
|
|
284
|
+
key={c.name}
|
|
285
|
+
className={`border-b border-slate-100 ${hasStages ? 'cursor-pointer hover:bg-slate-50' : ''}`}
|
|
286
|
+
onClick={hasStages ? () => toggleExpanded(c.name) : undefined}
|
|
287
|
+
>
|
|
288
|
+
<td className="py-2 text-slate-800 font-semibold">
|
|
289
|
+
<div className="flex items-center gap-1.5">
|
|
290
|
+
{hasStages
|
|
291
|
+
? (isOpen
|
|
292
|
+
? <ChevronDown className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
|
293
|
+
: <ChevronRight className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />)
|
|
294
|
+
: <span className="w-3.5 flex-shrink-0" />
|
|
295
|
+
}
|
|
296
|
+
{formatCeremonyName(c.name)}
|
|
297
|
+
</div>
|
|
298
|
+
</td>
|
|
299
|
+
<td className="py-2 text-right text-slate-500">{c.calls}</td>
|
|
300
|
+
<td className="py-2 text-right text-slate-500">{formatTokens(c.tokens)}</td>
|
|
301
|
+
<td className="py-2 text-right text-slate-800 font-semibold">{formatCostDetail(c.cost)}</td>
|
|
302
|
+
<td className="py-2 pl-4">
|
|
303
|
+
<div className="flex items-center gap-2">
|
|
304
|
+
<div className="w-20 bg-slate-100 rounded-full h-1.5 flex-shrink-0">
|
|
305
|
+
<div
|
|
306
|
+
className="bg-blue-500 h-1.5 rounded-full"
|
|
307
|
+
style={{ width: `${pct}%` }}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
<span className="text-xs text-slate-400">{Math.round(pct)}%</span>
|
|
311
|
+
</div>
|
|
312
|
+
</td>
|
|
313
|
+
</tr>,
|
|
314
|
+
|
|
315
|
+
/* Stage rows — shown when expanded */
|
|
316
|
+
...(isOpen && hasStages ? c.stages.map((s) => {
|
|
317
|
+
const stagePct = c.cost > 0 ? (s.cost / c.cost) * 100 : 0;
|
|
318
|
+
return (
|
|
319
|
+
<tr key={`${c.name}/${s.name}`} className="border-b border-slate-50 bg-slate-50/50">
|
|
320
|
+
<td className="py-1.5 text-slate-500 pl-7">
|
|
321
|
+
{formatStageName(s.name, c.name)}
|
|
322
|
+
</td>
|
|
323
|
+
<td className="py-1.5 text-right text-slate-400 text-xs">{s.calls}</td>
|
|
324
|
+
<td className="py-1.5 text-right text-slate-400 text-xs">{formatTokens(s.tokens)}</td>
|
|
325
|
+
<td className="py-1.5 text-right text-slate-500 text-xs">{formatCostDetail(s.cost)}</td>
|
|
326
|
+
<td className="py-1.5 pl-4">
|
|
327
|
+
<div className="flex items-center gap-2">
|
|
328
|
+
<div className="w-20 bg-slate-100 rounded-full h-1 flex-shrink-0">
|
|
329
|
+
<div
|
|
330
|
+
className="bg-blue-300 h-1 rounded-full"
|
|
331
|
+
style={{ width: `${stagePct}%` }}
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
<span className="text-[10px] text-slate-300">{Math.round(stagePct)}%</span>
|
|
335
|
+
</div>
|
|
336
|
+
</td>
|
|
337
|
+
</tr>
|
|
338
|
+
);
|
|
339
|
+
}) : []),
|
|
340
|
+
];
|
|
341
|
+
})}
|
|
342
|
+
</tbody>
|
|
343
|
+
</table>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
</>
|
|
348
|
+
)}
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cn } from '../../lib/utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Badge Component (shadcn/ui style)
|
|
5
|
+
*/
|
|
6
|
+
export function Badge({ variant = 'default', className, children }) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className={cn(
|
|
10
|
+
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold',
|
|
11
|
+
'transition-colors focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2',
|
|
12
|
+
{
|
|
13
|
+
'border-transparent bg-slate-900 text-slate-50 hover:bg-slate-900/80':
|
|
14
|
+
variant === 'default',
|
|
15
|
+
'border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80':
|
|
16
|
+
variant === 'secondary',
|
|
17
|
+
'border-transparent bg-red-100 text-red-900 hover:bg-red-100/80':
|
|
18
|
+
variant === 'destructive',
|
|
19
|
+
'border-slate-200 text-slate-900': variant === 'outline',
|
|
20
|
+
},
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dialog Component (shadcn/ui style)
|
|
8
|
+
* Modal dialog with backdrop and animations
|
|
9
|
+
*/
|
|
10
|
+
export function Dialog({ open, onOpenChange, children }) {
|
|
11
|
+
// Close on Escape key
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const handleEscape = (e) => {
|
|
14
|
+
if (e.key === 'Escape' && open) {
|
|
15
|
+
onOpenChange(false);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
document.addEventListener('keydown', handleEscape);
|
|
20
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
21
|
+
}, [open, onOpenChange]);
|
|
22
|
+
|
|
23
|
+
// Prevent body scroll when dialog is open
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (open) {
|
|
26
|
+
document.body.style.overflow = 'hidden';
|
|
27
|
+
} else {
|
|
28
|
+
document.body.style.overflow = 'unset';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
document.body.style.overflow = 'unset';
|
|
33
|
+
};
|
|
34
|
+
}, [open]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<AnimatePresence>
|
|
38
|
+
{open && (
|
|
39
|
+
<>
|
|
40
|
+
{/* Backdrop */}
|
|
41
|
+
<motion.div
|
|
42
|
+
initial={{ opacity: 0 }}
|
|
43
|
+
animate={{ opacity: 1 }}
|
|
44
|
+
exit={{ opacity: 0 }}
|
|
45
|
+
onClick={() => onOpenChange(false)}
|
|
46
|
+
className="fixed inset-0 bg-black/50 z-50"
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
{/* Dialog Container */}
|
|
50
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
</>
|
|
54
|
+
)}
|
|
55
|
+
</AnimatePresence>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Dialog Content
|
|
61
|
+
*/
|
|
62
|
+
export function DialogContent({ className, children, onClose }) {
|
|
63
|
+
return (
|
|
64
|
+
<motion.div
|
|
65
|
+
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
66
|
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
67
|
+
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
68
|
+
transition={{ duration: 0.2 }}
|
|
69
|
+
onClick={(e) => e.stopPropagation()}
|
|
70
|
+
className={cn(
|
|
71
|
+
'relative bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] overflow-hidden',
|
|
72
|
+
'flex flex-col',
|
|
73
|
+
className
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{/* Close button */}
|
|
77
|
+
<button
|
|
78
|
+
onClick={onClose}
|
|
79
|
+
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity z-10"
|
|
80
|
+
>
|
|
81
|
+
<X className="h-5 w-5" />
|
|
82
|
+
<span className="sr-only">Close</span>
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
{children}
|
|
86
|
+
</motion.div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Dialog Header
|
|
92
|
+
*/
|
|
93
|
+
export function DialogHeader({ className, children }) {
|
|
94
|
+
return (
|
|
95
|
+
<div className={cn('flex flex-col space-y-1.5 px-6 pt-6 pb-4', className)}>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Dialog Title
|
|
103
|
+
*/
|
|
104
|
+
export function DialogTitle({ className, children }) {
|
|
105
|
+
return (
|
|
106
|
+
<h2 className={cn('text-2xl font-semibold leading-none tracking-tight', className)}>
|
|
107
|
+
{children}
|
|
108
|
+
</h2>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Dialog Description
|
|
114
|
+
*/
|
|
115
|
+
export function DialogDescription({ className, children }) {
|
|
116
|
+
return (
|
|
117
|
+
<p className={cn('text-sm text-slate-600', className)}>
|
|
118
|
+
{children}
|
|
119
|
+
</p>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
2
|
+
import { cn } from '../../lib/utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tabs Component (shadcn/ui style)
|
|
6
|
+
* Tabbed interface with keyboard navigation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const TabsContext = createContext();
|
|
10
|
+
|
|
11
|
+
export function Tabs({ defaultValue, value, onValueChange, children, className }) {
|
|
12
|
+
const [selectedTab, setSelectedTab] = useState(value || defaultValue);
|
|
13
|
+
|
|
14
|
+
// Sync internal state when controlled value prop changes
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (value !== undefined) {
|
|
17
|
+
setSelectedTab(value);
|
|
18
|
+
}
|
|
19
|
+
}, [value]);
|
|
20
|
+
|
|
21
|
+
const handleTabChange = (newValue) => {
|
|
22
|
+
setSelectedTab(newValue);
|
|
23
|
+
onValueChange?.(newValue);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<TabsContext.Provider value={{ selectedTab, setSelectedTab: handleTabChange }}>
|
|
28
|
+
<div className={cn('w-full', className)}>{children}</div>
|
|
29
|
+
</TabsContext.Provider>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function TabsList({ className, children }) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
'inline-flex h-10 items-center justify-start rounded-md bg-slate-100 p-1 text-slate-600',
|
|
38
|
+
className
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function TabsTrigger({ value, children, className }) {
|
|
47
|
+
const { selectedTab, setSelectedTab } = useContext(TabsContext);
|
|
48
|
+
const isSelected = selectedTab === value;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => setSelectedTab(value)}
|
|
53
|
+
className={cn(
|
|
54
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5',
|
|
55
|
+
'text-sm font-medium ring-offset-white transition-all',
|
|
56
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2',
|
|
57
|
+
'disabled:pointer-events-none disabled:opacity-50',
|
|
58
|
+
isSelected
|
|
59
|
+
? 'bg-white text-slate-900 shadow-sm'
|
|
60
|
+
: 'text-slate-600 hover:bg-slate-200',
|
|
61
|
+
className
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TabsContent({ value, children, className }) {
|
|
70
|
+
const { selectedTab } = useContext(TabsContext);
|
|
71
|
+
|
|
72
|
+
if (selectedTab !== value) return null;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
className={cn(
|
|
77
|
+
'mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2',
|
|
78
|
+
'focus-visible:ring-slate-950 focus-visible:ring-offset-2',
|
|
79
|
+
className
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|