Full graph-based workflow execution engine with: - WorkflowGraphRuntime with two-phase dispatch (collect → fire Celery tasks) - Node registry with contract validation, socket types, execution kinds - WorkflowRuntimeServices: preflight, context resolution, shadow-mode A/B - WorkflowRouter: CRUD + dispatch/preflight/run-history API endpoints - OutputTypeContracts: workflow binding, rollout-mode resolution - Admin router: output-type workflow binding endpoints - Frontend: complete workflow editor with drag-drop canvas, node inspector, module bundles, reference bundles, preflight panel, validation banner, authoring guidance, blueprint templates, shadow/rollout gate UI - Tests: comprehensive coverage for all workflow modules - Docs: NODE_CONTRACT_AUDIT and VALIDATION_ERROR_INVENTORY All 20 blocks from NEXT_20_BLOCK_BATCH_PLAN completed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
586 lines
28 KiB
TypeScript
586 lines
28 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
import { ArrowRight, Plus } from 'lucide-react'
|
|
|
|
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
|
import {
|
|
AUTHORING_STAGE_DESCRIPTIONS,
|
|
AUTHORING_STAGE_LABELS,
|
|
AUTHORING_STAGE_STYLES,
|
|
CATEGORY_COLORS,
|
|
CATEGORY_LABELS,
|
|
FAMILY_FILTER_DESCRIPTIONS,
|
|
FAMILY_FILTER_LABELS,
|
|
FAMILY_FILTER_STYLES,
|
|
NODE_KIND_FILTER_LABELS,
|
|
NODE_LIBRARY_GROUP_LABELS,
|
|
NODE_LIBRARY_GROUP_STYLES,
|
|
getDefinitionBadges,
|
|
getDefinitionFamily,
|
|
getDefinitionModuleLabel,
|
|
getDefinitionModuleNamespace,
|
|
type WorkflowAuthoringStage,
|
|
type WorkflowGraphFamily,
|
|
type WorkflowNodeFamilyFilter,
|
|
type WorkflowNodeKindFilter,
|
|
type WorkflowNodeLibraryGroup,
|
|
} from './workflowNodeLibrary'
|
|
import {
|
|
buildWorkflowNodeCatalogModel,
|
|
filterWorkflowNodeDefinitions,
|
|
getAvailableFamilyFilters,
|
|
} from './workflowNodeCatalog'
|
|
import { STARTER_NODE_STEP_ORDER, STARTER_PATH_TITLES } from './workflowAuthoringGuidance'
|
|
import {
|
|
CATEGORY_FILTER_LABELS,
|
|
CATEGORY_FILTERS,
|
|
type FilterPillOption,
|
|
type WorkflowNodeCategoryFilter,
|
|
WorkflowNodeCatalogFilterControls,
|
|
} from './WorkflowNodeCatalogFilterControls'
|
|
import { WorkflowNodeCatalogEmptyState } from './WorkflowNodeCatalogEmptyState'
|
|
import { WorkflowNodeCatalogQuickInsert } from './WorkflowNodeCatalogQuickInsert'
|
|
import {
|
|
getWorkflowNodeContractPresentation,
|
|
getWorkflowNodeContractSignals,
|
|
getWorkflowNodeContractSummary,
|
|
} from './workflowNodeContracts'
|
|
import { WorkflowNodeContractSignalList } from './WorkflowNodeContractSignalList'
|
|
|
|
function ContractSummaryMetric({
|
|
label,
|
|
value,
|
|
title,
|
|
}: {
|
|
label: string
|
|
value: string
|
|
title?: string
|
|
}) {
|
|
return (
|
|
<div
|
|
className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1"
|
|
title={title}
|
|
>
|
|
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">{label}</p>
|
|
<p className="mt-0.5 text-[11px] font-medium text-content">{value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function RuntimeCoverageBadges({
|
|
runtimeCounts,
|
|
size = 'sm',
|
|
}: {
|
|
runtimeCounts: Partial<Record<WorkflowNodeLibraryGroup, number>>
|
|
size?: 'sm' | 'xs'
|
|
}) {
|
|
return (
|
|
<>
|
|
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
|
const count = runtimeCounts[group] ?? 0
|
|
if (count === 0) return null
|
|
const label = size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : NODE_KIND_FILTER_LABELS[group]
|
|
|
|
return (
|
|
<span
|
|
key={group}
|
|
className={
|
|
size === 'xs'
|
|
? `rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
|
|
: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`
|
|
}
|
|
title={size === 'sm' ? NODE_LIBRARY_GROUP_LABELS[group] : undefined}
|
|
>
|
|
<span>{label}</span>
|
|
<span>{count}</span>
|
|
</span>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function StageCoverageBadges({
|
|
stages,
|
|
}: {
|
|
stages: WorkflowAuthoringStage[]
|
|
}) {
|
|
return (
|
|
<>
|
|
{stages.map(stage => (
|
|
<span
|
|
key={stage}
|
|
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[stage]}`}
|
|
title={AUTHORING_STAGE_DESCRIPTIONS[stage]}
|
|
>
|
|
{AUTHORING_STAGE_LABELS[stage]}
|
|
</span>
|
|
))}
|
|
</>
|
|
)
|
|
}
|
|
|
|
type WorkflowNodeCatalogBrowserProps = {
|
|
definitions: WorkflowNodeDefinition[]
|
|
graphFamily: WorkflowGraphFamily
|
|
variant?: 'menu' | 'panel'
|
|
onSelectStep?: (step: string) => void
|
|
onEmptyAction?: () => void
|
|
emptyActionLabel?: string
|
|
renderIcon?: (iconName?: string, size?: number) => ReactNode
|
|
searchPlaceholder?: string
|
|
autoFocusSearch?: boolean
|
|
}
|
|
|
|
export function WorkflowNodeCatalogBrowser({
|
|
definitions,
|
|
graphFamily,
|
|
variant = 'panel',
|
|
onSelectStep,
|
|
onEmptyAction,
|
|
emptyActionLabel = 'Clear Filters',
|
|
renderIcon,
|
|
searchPlaceholder = 'Search node label, step, or capability',
|
|
autoFocusSearch = false,
|
|
}: WorkflowNodeCatalogBrowserProps) {
|
|
const [query, setQuery] = useState('')
|
|
const [familyFilter, setFamilyFilter] = useState<WorkflowNodeFamilyFilter>(
|
|
graphFamily === 'mixed' ? 'all' : graphFamily,
|
|
)
|
|
const [kindFilter, setKindFilter] = useState<WorkflowNodeKindFilter>('all')
|
|
const [categoryFilter, setCategoryFilter] = useState<WorkflowNodeCategoryFilter>('all')
|
|
const [moduleFilter, setModuleFilter] = useState<string>('all')
|
|
const [moduleQuery, setModuleQuery] = useState('')
|
|
|
|
useEffect(() => {
|
|
setFamilyFilter(graphFamily === 'mixed' ? 'all' : graphFamily)
|
|
setCategoryFilter('all')
|
|
setModuleFilter('all')
|
|
setModuleQuery('')
|
|
}, [graphFamily])
|
|
|
|
const availableFamilyFilters = useMemo(
|
|
() => getAvailableFamilyFilters(graphFamily),
|
|
[graphFamily],
|
|
)
|
|
|
|
const filteredDefinitions = useMemo(() => {
|
|
return filterWorkflowNodeDefinitions(definitions, {
|
|
graphFamily,
|
|
familyFilter,
|
|
kindFilter,
|
|
query,
|
|
})
|
|
}, [definitions, familyFilter, graphFamily, kindFilter, query])
|
|
|
|
const normalizedModuleQuery = moduleQuery.trim().toLowerCase()
|
|
|
|
const visibleDefinitions = useMemo(() => {
|
|
const scopedByModule =
|
|
moduleFilter === 'all'
|
|
? filteredDefinitions
|
|
: filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
|
|
|
|
const scopedByModuleQuery = !normalizedModuleQuery
|
|
? scopedByModule
|
|
: scopedByModule.filter(definition => {
|
|
const namespace = getDefinitionModuleNamespace(definition).toLowerCase()
|
|
return (
|
|
namespace.includes(normalizedModuleQuery) ||
|
|
getDefinitionModuleLabel(definition).toLowerCase().includes(normalizedModuleQuery) ||
|
|
definition.module_key.toLowerCase().includes(normalizedModuleQuery) ||
|
|
definition.label.toLowerCase().includes(normalizedModuleQuery)
|
|
)
|
|
})
|
|
|
|
if (categoryFilter === 'all') return scopedByModuleQuery
|
|
return scopedByModuleQuery.filter(definition => definition.category === categoryFilter)
|
|
}, [categoryFilter, filteredDefinitions, moduleFilter, normalizedModuleQuery])
|
|
|
|
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
|
|
const moduleFilters = catalogModel.moduleFilters
|
|
const stageSections = catalogModel.stageSections
|
|
|
|
useEffect(() => {
|
|
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
|
|
setModuleFilter('all')
|
|
}
|
|
}, [moduleFilter, moduleFilters])
|
|
|
|
const familySections = catalogModel.familySections
|
|
const firstVisibleDefinition = visibleDefinitions[0]
|
|
const totalModuleCount = moduleFilters.length
|
|
const shouldExpandStageCoverage = variant === 'panel' && query.trim().length === 0 && moduleFilter === 'all'
|
|
const quickInsertDefinitions = useMemo(() => {
|
|
const prioritizedSteps = STARTER_NODE_STEP_ORDER[graphFamily]
|
|
if (prioritizedSteps.length === 0) return visibleDefinitions.slice(0, 4)
|
|
|
|
const byStep = new Map(visibleDefinitions.map(definition => [definition.step, definition]))
|
|
return prioritizedSteps
|
|
.map(step => byStep.get(step))
|
|
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
|
|
.slice(0, variant === 'menu' ? 5 : 6)
|
|
}, [graphFamily, variant, visibleDefinitions])
|
|
const quickInsertTitle = graphFamily === 'mixed' ? 'Suggested nodes' : STARTER_PATH_TITLES[graphFamily]
|
|
const runtimeFilterOptions: FilterPillOption<WorkflowNodeKindFilter>[] = [
|
|
{ value: 'all', label: NODE_KIND_FILTER_LABELS.all },
|
|
{ value: 'legacy', label: NODE_KIND_FILTER_LABELS.legacy },
|
|
{ value: 'bridge', label: NODE_KIND_FILTER_LABELS.bridge },
|
|
{ value: 'graph', label: NODE_KIND_FILTER_LABELS.graph },
|
|
]
|
|
const familyFilterOptions = availableFamilyFilters.map(filter => ({
|
|
value: filter,
|
|
label: FAMILY_FILTER_LABELS[filter],
|
|
}))
|
|
const categoryFilterOptions: FilterPillOption<WorkflowNodeCategoryFilter>[] = CATEGORY_FILTERS.map(
|
|
filter => ({
|
|
value: filter,
|
|
label: CATEGORY_FILTER_LABELS[filter],
|
|
}),
|
|
)
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<WorkflowNodeCatalogFilterControls
|
|
visibleDefinitionCount={visibleDefinitions.length}
|
|
totalModuleCount={totalModuleCount}
|
|
graphFamily={graphFamily}
|
|
familyFilterOptions={familyFilterOptions}
|
|
runtimeFilterOptions={runtimeFilterOptions}
|
|
categoryFilterOptions={categoryFilterOptions}
|
|
familyFilter={familyFilter}
|
|
onFamilyFilterChange={setFamilyFilter}
|
|
kindFilter={kindFilter}
|
|
onKindFilterChange={setKindFilter}
|
|
categoryFilter={categoryFilter}
|
|
onCategoryFilterChange={setCategoryFilter}
|
|
query={query}
|
|
onQueryChange={setQuery}
|
|
onQueryEnter={() => {
|
|
if (firstVisibleDefinition && onSelectStep) {
|
|
onSelectStep(firstVisibleDefinition.step)
|
|
}
|
|
}}
|
|
searchPlaceholder={searchPlaceholder}
|
|
autoFocusSearch={autoFocusSearch}
|
|
moduleFilters={moduleFilters}
|
|
moduleFilter={moduleFilter}
|
|
onModuleFilterChange={setModuleFilter}
|
|
moduleQuery={moduleQuery}
|
|
onModuleQueryChange={setModuleQuery}
|
|
onClearModuleScope={() => {
|
|
setModuleFilter('all')
|
|
setModuleQuery('')
|
|
}}
|
|
/>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<RuntimeCoverageBadges runtimeCounts={catalogModel.runtimeCounts} />
|
|
</div>
|
|
|
|
{stageSections.length > 0 && (
|
|
<details
|
|
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
|
|
open={shouldExpandStageCoverage}
|
|
>
|
|
<summary className="flex cursor-pointer list-none flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
|
Stage Coverage
|
|
</p>
|
|
<p className="mt-1 text-xs text-content-muted">
|
|
Use Quick Insert for the first canonical steps, then use stage coverage to inspect which modules can fill the rest of the production path.
|
|
</p>
|
|
</div>
|
|
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
|
{stageSections.length} active stages
|
|
</span>
|
|
</summary>
|
|
<div className="mt-3 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
|
{stageSections.map(section => (
|
|
<div
|
|
key={`stage-summary-${section.stage}`}
|
|
className="rounded-lg border border-border-default bg-surface px-3 py-2"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[section.stage]}`}>
|
|
{AUTHORING_STAGE_LABELS[section.stage]}
|
|
</span>
|
|
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
|
|
{section.modules.length} modules
|
|
</span>
|
|
<span className="rounded-full border border-border-default bg-surface-hover px-1.5 py-0.5 text-[10px] text-content-muted">
|
|
{section.definitions.length} nodes
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-xs text-content-muted">
|
|
{AUTHORING_STAGE_DESCRIPTIONS[section.stage]}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</details>
|
|
)}
|
|
|
|
<WorkflowNodeCatalogQuickInsert
|
|
title={quickInsertTitle}
|
|
definitions={quickInsertDefinitions}
|
|
onSelectStep={onSelectStep}
|
|
/>
|
|
|
|
{visibleDefinitions.length === 0 && (
|
|
<WorkflowNodeCatalogEmptyState
|
|
onResetFilters={onEmptyAction}
|
|
resetLabel={emptyActionLabel}
|
|
/>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
{familySections.map(familySection => {
|
|
const familyLabel = FAMILY_FILTER_LABELS[familySection.family]
|
|
const familyDescription = FAMILY_FILTER_DESCRIPTIONS[familySection.family]
|
|
const familyStyle = FAMILY_FILTER_STYLES[familySection.family]
|
|
|
|
return (
|
|
<div
|
|
key={familySection.family}
|
|
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${familyStyle}`}>
|
|
{familyLabel}
|
|
</span>
|
|
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
|
{familySection.modules.length} modules
|
|
</span>
|
|
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
|
{familySection.definitions.length} nodes
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-content-muted">{familyDescription}</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<RuntimeCoverageBadges runtimeCounts={familySection.runtimeCounts} size="xs" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-3">
|
|
{familySection.modules.map(moduleGroup => (
|
|
<div
|
|
key={`${familySection.family}:${moduleGroup.namespace}`}
|
|
className="rounded-lg border border-border-default bg-surface/80 p-3"
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-xs font-medium text-content">
|
|
{moduleGroup.label}
|
|
</span>
|
|
<span className="truncate rounded-full border border-border-default bg-surface px-2 py-0.5 font-mono text-[10px] text-content-muted">
|
|
{moduleGroup.namespace}
|
|
</span>
|
|
<StageCoverageBadges stages={moduleGroup.stages} />
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<RuntimeCoverageBadges runtimeCounts={moduleGroup.runtimeCounts} size="xs" />
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-3">
|
|
{moduleGroup.stageSections.map(stageSection => {
|
|
const stageLabel = AUTHORING_STAGE_LABELS[stageSection.stage]
|
|
const stageDescription = AUTHORING_STAGE_DESCRIPTIONS[stageSection.stage]
|
|
const stageStyle = AUTHORING_STAGE_STYLES[stageSection.stage]
|
|
|
|
return (
|
|
<div
|
|
key={`${familySection.family}:${moduleGroup.namespace}:${stageSection.stage}`}
|
|
className="rounded-md border border-border-default bg-surface-hover/35 p-2"
|
|
>
|
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${stageStyle}`}>
|
|
{stageLabel}
|
|
</span>
|
|
<p className="text-xs text-content-muted">{stageDescription}</p>
|
|
</div>
|
|
<span className="text-[10px] text-content-muted">
|
|
{stageSection.definitions.length}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{stageSection.categories.map(categorySection => {
|
|
const { category, definitions: categoryDefinitions } = categorySection
|
|
return (
|
|
<div key={`${moduleGroup.namespace}:${stageSection.stage}:${category}`}>
|
|
<div className="mb-1 flex items-center justify-between gap-2">
|
|
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
|
|
{CATEGORY_LABELS[category]}
|
|
</span>
|
|
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
{categoryDefinitions.map(definition => {
|
|
const family = getDefinitionFamily(definition)
|
|
const contractSummary = getWorkflowNodeContractSummary(definition)
|
|
const contractPresentation =
|
|
getWorkflowNodeContractPresentation(contractSummary)
|
|
const {
|
|
inputMetric,
|
|
variableMetric,
|
|
outputMetric,
|
|
socketRequirementDescription,
|
|
variableSummaryText,
|
|
} = contractPresentation
|
|
const contractSignals = getWorkflowNodeContractSignals(
|
|
definition,
|
|
contractSummary,
|
|
)
|
|
const isActionable = Boolean(onSelectStep)
|
|
|
|
return (
|
|
<div
|
|
key={definition.step}
|
|
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
|
|
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
|
|
}`}
|
|
title={definition.description}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
{renderIcon && (
|
|
<span className="mt-0.5 text-content-secondary">
|
|
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
|
|
</span>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<p className="truncate text-sm font-medium text-content">
|
|
{definition.label}
|
|
</p>
|
|
<span
|
|
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}
|
|
>
|
|
{FAMILY_FILTER_LABELS[family]}
|
|
</span>
|
|
{getDefinitionBadges(definition).map(badge => (
|
|
<span
|
|
key={`${definition.step}-${badge.label}`}
|
|
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
|
|
>
|
|
{badge.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">
|
|
{definition.step}
|
|
</p>
|
|
</div>
|
|
|
|
{isActionable && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectStep?.(definition.step)}
|
|
aria-label={
|
|
variant === 'panel'
|
|
? `Insert ${definition.label}`
|
|
: `Use ${definition.label}`
|
|
}
|
|
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
|
|
variant === 'panel'
|
|
? 'border border-border-default text-content hover:bg-surface-hover'
|
|
: 'bg-accent text-white hover:bg-accent-hover'
|
|
}`}
|
|
>
|
|
{variant === 'panel' ? (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Plus size={12} />
|
|
Insert
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1">
|
|
Use
|
|
<ArrowRight size={12} />
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<p className="mt-1 line-clamp-2 text-xs text-content-muted">
|
|
{definition.description}
|
|
</p>
|
|
|
|
<WorkflowNodeContractSignalList
|
|
signals={contractSignals}
|
|
className="mt-2 flex flex-wrap gap-1.5 text-[10px]"
|
|
/>
|
|
|
|
<div className="mt-2 grid gap-1.5 sm:grid-cols-3">
|
|
<ContractSummaryMetric
|
|
label="Inputs"
|
|
value={inputMetric.value}
|
|
title={inputMetric.title}
|
|
/>
|
|
<ContractSummaryMetric
|
|
label="Variables"
|
|
value={variableMetric.value}
|
|
title={variableMetric.title}
|
|
/>
|
|
<ContractSummaryMetric
|
|
label="Outputs"
|
|
value={outputMetric.value}
|
|
title={outputMetric.title}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-2 grid gap-1.5 lg:grid-cols-2">
|
|
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
|
|
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
|
|
Wiring
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-content-secondary">
|
|
{socketRequirementDescription}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1.5">
|
|
<p className="text-[9px] font-semibold uppercase tracking-wide text-content-muted">
|
|
Variables
|
|
</p>
|
|
<p className="mt-1 text-[11px] text-content-muted">
|
|
{variableSummaryText}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|