diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json index 007f847..cdfe4ac 100644 --- a/packages/governance-widget/package.json +++ b/packages/governance-widget/package.json @@ -21,7 +21,14 @@ }, "peerDependencies": { "react": ">=18.0.0", - "react-dom": ">=18.0.0" + "react-dom": ">=18.0.0", + "react-native": ">=0.76.0", + "react-native-web": ">=0.19.0" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } }, "dependencies": { "@goodwidget/ui": "workspace:*", @@ -33,6 +40,8 @@ "@types/react-dom": "^18.3.0", "react": "^18.3.0", "react-dom": "^18.3.0", + "react-native": "0.76.9", + "react-native-web": "^0.19.13", "tsup": "^8.4.0", "typescript": "^5.7.0" } diff --git a/packages/governance-widget/src/BalanceCard.tsx b/packages/governance-widget/src/BalanceCard.tsx new file mode 100644 index 0000000..e1bb21a --- /dev/null +++ b/packages/governance-widget/src/BalanceCard.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { Card, Icon, Text, XStack } from '@goodwidget/ui' +import type { BalanceCardProps } from './types' +import { isGovernanceAmount, renderGovernanceAmount } from './utils' + +export function BalanceCard({ + icon, + title, + amount, + amountType = 'token', + metadata, + compact = false, + testID, +}: BalanceCardProps) { + const amountValue = isGovernanceAmount(amount) ? amount : { value: amount, token: amountType === 'token' ? 'G$' : undefined } + const metadataTone = metadata.tone === 'positive' ? 'default' : metadata.tone === 'muted' ? 'secondary' : 'soft' + + return ( + + + + + {title} + + + {renderGovernanceAmount(amountValue, compact ? 'md' : 'lg')} + + {metadata.icon ? : null} + + {metadata.label} + + + + ) +} diff --git a/packages/governance-widget/src/FundingDistributionChart.tsx b/packages/governance-widget/src/FundingDistributionChart.tsx new file mode 100644 index 0000000..3047d4f --- /dev/null +++ b/packages/governance-widget/src/FundingDistributionChart.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { Card, Heading, Text, XStack, YStack } from '@goodwidget/ui' +import { useTheme } from 'tamagui' +import { FundingDonut, FundingLegend } from './primitives' +import type { FundingDistributionChartProps } from './types' +import { DONUT_COLOR_KEYS, resolveThemeColor } from './utils' + +export function FundingDistributionChart({ + totalAmount, + projects, + isStreaming = false, + onProjectPress, + testID, +}: FundingDistributionChartProps) { + const theme = useTheme() + const fallbackColors = ['#2563eb', '#16a34a', '#d97706', '#0891b2', '#dc2626'] + const colors = DONUT_COLOR_KEYS.map((key, index) => resolveThemeColor(theme, key, fallbackColors[index])) + const total = { ...totalAmount, isStreaming: totalAmount.isStreaming ?? isStreaming } + + return ( + + + Funding distribution + Current allocation across active governance projects. + + + + + + + ) +} diff --git a/packages/governance-widget/src/ImpactCard.tsx b/packages/governance-widget/src/ImpactCard.tsx new file mode 100644 index 0000000..077526f --- /dev/null +++ b/packages/governance-widget/src/ImpactCard.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Button, ButtonText, Card, Heading, Text, XStack } from '@goodwidget/ui' +import { MetricBox } from './primitives' +import type { ImpactCardProps } from './types' + +export function ImpactCard({ + title, + metrics, + description, + ctaLabel, + ctaDisabled = false, + onCtaPress, + testID, +}: ImpactCardProps) { + return ( + + {title} + + {metrics.map((metric) => ( + + ))} + + {description} + {ctaLabel ? ( + + ) : null} + + ) +} diff --git a/packages/governance-widget/src/ProposalCards.tsx b/packages/governance-widget/src/ProposalCards.tsx new file mode 100644 index 0000000..eead95d --- /dev/null +++ b/packages/governance-widget/src/ProposalCards.tsx @@ -0,0 +1,101 @@ +import React from 'react' +import { Stack } from 'tamagui' +import { Card, Heading, Icon, Text, XStack, YStack } from '@goodwidget/ui' +import { ProposalHeader, RankedOptionRow, StackedProgressBar, VoteLegend, VoterPreviewGroup } from './primitives' +import type { AlignmentVotingProposalCardProps, OptimisticVotingProposalCardProps } from './types' +import { clampPercentage } from './utils' + +export function AlignmentVotingProposalCard({ + id, + categoryLabel, + title, + summaryLabel = 'Current top voted', + options, + maxVisibleOptions = 3, + onPress, + testID, +}: AlignmentVotingProposalCardProps) { + const visibleOptions = options.slice(0, maxVisibleOptions) + const hiddenCount = Math.max(0, options.length - visibleOptions.length) + + return ( + onPress(id) : undefined} + role={onPress ? 'button' : undefined} + aria-label={`Open proposal ${title}`} + > + + {title} + + + + {summaryLabel} + + + + {visibleOptions.map((option) => ( + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} more options + + ) : null} + + + ) +} + +export function OptimisticVotingProposalCard({ + id, + categoryLabel, + title, + quorumLabel = 'Current vote quorum', + quorumReachedPercent, + voteSegments, + voters, + remainingVoterCountLabel, + onPress, + testID, +}: OptimisticVotingProposalCardProps) { + return ( + onPress(id) : undefined} + role={onPress ? 'button' : undefined} + aria-label={`Open proposal ${title}`} + > + + {title} + + + + {quorumLabel} + + + {clampPercentage(quorumReachedPercent)}% reached + + + + + + + + + + + + + + + ) +} diff --git a/packages/governance-widget/src/governanceComponents.tsx b/packages/governance-widget/src/governanceComponents.tsx deleted file mode 100644 index ccce74e..0000000 --- a/packages/governance-widget/src/governanceComponents.tsx +++ /dev/null @@ -1,606 +0,0 @@ -import React from 'react' -import Svg, { Circle, G } from 'react-native-svg' -import { Stack, useTheme } from 'tamagui' -import { - Badge, - BadgeText, - Button, - ButtonText, - Card, - Heading, - Icon, - Text, - TokenAmount, - XStack, - YStack, -} from '@goodwidget/ui' -import type { IconName } from '@goodwidget/ui' - -export interface GovernanceAmount { - value: string | number - token?: string - isStreaming?: boolean - streamLabel?: string -} - -export interface ImpactCardMetric { - label: string - amount: GovernanceAmount - description?: string -} - -export interface ImpactCardProps { - title: string - metrics: [ImpactCardMetric, ImpactCardMetric] - description: string - ctaLabel?: string - ctaDisabled?: boolean - onCtaPress?: () => void - testID?: string -} - -export interface BalanceCardMetadata { - label: string - tone?: 'default' | 'positive' | 'muted' - icon?: IconName -} - -export interface BalanceCardProps { - icon: IconName - title: string - amount: GovernanceAmount | string | number - amountType?: 'token' | 'raw' - metadata: BalanceCardMetadata - compact?: boolean - testID?: string -} - -export interface RankedVotingOption { - id: string - label: string - percentage: number -} - -export interface AlignmentVotingProposalCardProps { - id: string - categoryLabel: string - title: string - summaryLabel?: string - options: RankedVotingOption[] - maxVisibleOptions?: number - onPress?: (id: string) => void - testID?: string -} - -export interface VoteSegment { - id: string - label: string - percentage: number - tone?: 'for' | 'against' | 'abstain' | 'neutral' -} - -export interface VoterPreview { - id: string - label: string - avatarUrl?: string -} - -export interface OptimisticVotingProposalCardProps { - id: string - categoryLabel: string - title: string - quorumLabel?: string - quorumReachedPercent: number - voteSegments: VoteSegment[] - voters: VoterPreview[] - remainingVoterCountLabel?: string - onPress?: (id: string) => void - testID?: string -} - -export interface FundingProjectAllocation { - id: string - name: string - amount: GovernanceAmount - percentage: number -} - -export interface FundingDistributionChartProps { - totalAmount: GovernanceAmount - projects: FundingProjectAllocation[] - isStreaming?: boolean - onProjectPress?: (id: string) => void - testID?: string -} - -const SEGMENT_TONES: Record, string> = { - for: '$primary', - against: '$error', - abstain: '$placeholderColor', - neutral: '$success', -} - -const DONUT_COLOR_KEYS = ['primary', 'success', 'warning', 'info', 'error'] as const - -function clampPercentage(value: number): number { - if (!Number.isFinite(value)) { - return 0 - } - - return Math.max(0, Math.min(100, value)) -} - -function formatRawValue(value: string | number): string { - if (typeof value === 'string') { - return value - } - - return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value) -} - -function isGovernanceAmount(value: GovernanceAmount | string | number): value is GovernanceAmount { - return typeof value === 'object' && value !== null && 'value' in value -} - -function resolveThemeColor(theme: ReturnType, key: string, fallback: string): string { - const themeValue = theme[key as keyof typeof theme] - - if (themeValue && typeof themeValue === 'object' && 'val' in themeValue) { - return String(themeValue.val) - } - - return fallback -} - -function renderGovernanceAmount(amount: GovernanceAmount, size: 'sm' | 'md' | 'lg' | 'xl' = 'lg') { - return ( - - {amount.token ? ( - - ) : ( - {formatRawValue(amount.value)} - )} - {amount.isStreaming ? ( - - {amount.streamLabel ?? 'Live stream'} - - ) : null} - - ) -} - -function MetricBox({ metric }: { metric: ImpactCardMetric }) { - return ( - - - {metric.label} - - {renderGovernanceAmount(metric.amount, 'lg')} - {metric.description ? ( - - {metric.description} - - ) : null} - - ) -} - -function ProposalHeader({ categoryLabel }: { categoryLabel: string }) { - return ( - - - {categoryLabel} - - - - ) -} - -function ProgressBar({ percentage, colorToken = '$primary' }: { percentage: number; colorToken?: string }) { - return ( - - - - ) -} - -function RankedOptionRow({ option }: { option: RankedVotingOption }) { - return ( - - - - {option.label} - - - {clampPercentage(option.percentage)}% - - - - - ) -} - -function StackedProgressBar({ segments }: { segments: VoteSegment[] }) { - return ( - - {segments.map((segment) => ( - - ))} - - ) -} - -function VoterAvatar({ voter, index }: { voter: VoterPreview; index: number }) { - const initial = voter.label.trim().slice(0, 1).toUpperCase() || '?' - - return ( - - {voter.avatarUrl ? ( - {voter.label} - ) : ( - - {initial} - - )} - - ) -} - -function VoterPreviewGroup({ voters, remainingLabel }: { voters: VoterPreview[]; remainingLabel?: string }) { - return ( - - - {voters.slice(0, 4).map((voter, index) => ( - - ))} - - {remainingLabel ? ( - - {remainingLabel} - - ) : null} - - ) -} - -function VoteLegend({ segments }: { segments: VoteSegment[] }) { - return ( - - {segments.map((segment) => ( - - - - {segment.label} {clampPercentage(segment.percentage)}% - - - ))} - - ) -} - -function fundingAmountLabel(amount: GovernanceAmount): string { - const base = amount.token ? `${formatRawValue(amount.value)} ${amount.token}` : formatRawValue(amount.value) - - if (amount.isStreaming) { - return `${base} streaming` - } - - return base -} - -function FundingLegend({ - projects, - colors, - onProjectPress, -}: { - projects: FundingProjectAllocation[] - colors: string[] - onProjectPress?: (id: string) => void -}) { - if (projects.length === 0) { - return ( - - No active funding distribution yet. - - ) - } - - return ( - - {projects.map((project, index) => ( - - ))} - - ) -} - -function FundingDonut({ - projects, - totalAmount, - colors, - onProjectPress, -}: { - projects: FundingProjectAllocation[] - totalAmount: GovernanceAmount - colors: string[] - onProjectPress?: (id: string) => void -}) { - const size = 196 - const strokeWidth = 22 - const radius = (size - strokeWidth) / 2 - const circumference = 2 * Math.PI * radius - let offset = 0 - - return ( - - - - - {projects.map((project, index) => { - const percentage = clampPercentage(project.percentage) - const dashLength = (percentage / 100) * circumference - const dashOffset = -offset - offset += dashLength - - return ( - onProjectPress(project.id) : undefined} - /> - ) - })} - - - - {renderGovernanceAmount(totalAmount, 'md')} - - Total active funding - - - - ) -} - -export function ImpactCard({ - title, - metrics, - description, - ctaLabel, - ctaDisabled = false, - onCtaPress, - testID, -}: ImpactCardProps) { - return ( - - {title} - - {metrics.map((metric) => ( - - ))} - - {description} - {ctaLabel ? ( - - ) : null} - - ) -} - -export function BalanceCard({ - icon, - title, - amount, - amountType = 'token', - metadata, - compact = false, - testID, -}: BalanceCardProps) { - const amountValue = isGovernanceAmount(amount) ? amount : { value: amount, token: amountType === 'token' ? 'G$' : undefined } - const metadataTone = metadata.tone === 'positive' ? 'default' : metadata.tone === 'muted' ? 'secondary' : 'soft' - - return ( - - - - - {title} - - - {renderGovernanceAmount(amountValue, compact ? 'md' : 'lg')} - - {metadata.icon ? : null} - - {metadata.label} - - - - ) -} - -export function AlignmentVotingProposalCard({ - id, - categoryLabel, - title, - summaryLabel = 'Current top voted', - options, - maxVisibleOptions = 3, - onPress, - testID, -}: AlignmentVotingProposalCardProps) { - const visibleOptions = options.slice(0, maxVisibleOptions) - const hiddenCount = Math.max(0, options.length - visibleOptions.length) - - return ( - onPress(id) : undefined} - role={onPress ? 'button' : undefined} - aria-label={`Open proposal ${title}`} - > - - {title} - - - - {summaryLabel} - - - - {visibleOptions.map((option) => ( - - ))} - {hiddenCount > 0 ? ( - - +{hiddenCount} more options - - ) : null} - - - ) -} - -export function OptimisticVotingProposalCard({ - id, - categoryLabel, - title, - quorumLabel = 'Current vote quorum', - quorumReachedPercent, - voteSegments, - voters, - remainingVoterCountLabel, - onPress, - testID, -}: OptimisticVotingProposalCardProps) { - return ( - onPress(id) : undefined} - role={onPress ? 'button' : undefined} - aria-label={`Open proposal ${title}`} - > - - {title} - - - - {quorumLabel} - - - {clampPercentage(quorumReachedPercent)}% reached - - - - - - - - - - - - - - - ) -} - -export function FundingDistributionChart({ - totalAmount, - projects, - isStreaming = false, - onProjectPress, - testID, -}: FundingDistributionChartProps) { - const theme = useTheme() - const fallbackColors = ['#2563eb', '#16a34a', '#d97706', '#0891b2', '#dc2626'] - const colors = DONUT_COLOR_KEYS.map((key, index) => resolveThemeColor(theme, key, fallbackColors[index])) - const total = { ...totalAmount, isStreaming: totalAmount.isStreaming ?? isStreaming } - - return ( - - - Funding distribution - Current allocation across active governance projects. - - - - - - - ) -} diff --git a/packages/governance-widget/src/index.ts b/packages/governance-widget/src/index.ts index 0ad8bec..bc01012 100644 --- a/packages/governance-widget/src/index.ts +++ b/packages/governance-widget/src/index.ts @@ -1,10 +1,16 @@ export { ImpactCard, +} from './ImpactCard' +export { BalanceCard, +} from './BalanceCard' +export { AlignmentVotingProposalCard, OptimisticVotingProposalCard, +} from './ProposalCards' +export { FundingDistributionChart, -} from './governanceComponents' +} from './FundingDistributionChart' export type { GovernanceAmount, ImpactCardMetric, @@ -18,4 +24,4 @@ export type { OptimisticVotingProposalCardProps, FundingProjectAllocation, FundingDistributionChartProps, -} from './governanceComponents' +} from './types' diff --git a/packages/governance-widget/src/primitives.tsx b/packages/governance-widget/src/primitives.tsx new file mode 100644 index 0000000..1c9e8a6 --- /dev/null +++ b/packages/governance-widget/src/primitives.tsx @@ -0,0 +1,269 @@ +import React from 'react' +import { Image } from 'react-native' +import Svg, { Circle, G } from 'react-native-svg' +import { Stack } from 'tamagui' +import { Badge, BadgeText, Button, Icon, Text, XStack, YStack } from '@goodwidget/ui' +import type { FundingProjectAllocation, GovernanceAmount, ImpactCardMetric, RankedVotingOption, VoteSegment, VoterPreview } from './types' +import { clampPercentage, formatRawValue, renderGovernanceAmount, SEGMENT_TONES } from './utils' + +export function MetricBox({ metric }: { metric: ImpactCardMetric }) { + return ( + + + {metric.label} + + {renderGovernanceAmount(metric.amount, 'lg')} + {metric.description ? ( + + {metric.description} + + ) : null} + + ) +} + +export function ProposalHeader({ categoryLabel }: { categoryLabel: string }) { + return ( + + + {categoryLabel} + + + + ) +} + +export function ProgressBar({ percentage, colorToken = '$primary' }: { percentage: number; colorToken?: string }) { + return ( + + + + ) +} + +export function RankedOptionRow({ option }: { option: RankedVotingOption }) { + return ( + + + + {option.label} + + + {clampPercentage(option.percentage)}% + + + + + ) +} + +export function StackedProgressBar({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + ))} + + ) +} + +function VoterAvatar({ voter, index }: { voter: VoterPreview; index: number }) { + const initial = voter.label.trim().slice(0, 1).toUpperCase() || '?' + + return ( + + {voter.avatarUrl ? ( + + ) : ( + + {initial} + + )} + + ) +} + +export function VoterPreviewGroup({ voters, remainingLabel }: { voters: VoterPreview[]; remainingLabel?: string }) { + return ( + + + {voters.slice(0, 4).map((voter, index) => ( + + ))} + + {remainingLabel ? ( + + {remainingLabel} + + ) : null} + + ) +} + +export function VoteLegend({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + + + {segment.label} {clampPercentage(segment.percentage)}% + + + ))} + + ) +} + +function fundingAmountLabel(amount: GovernanceAmount): string { + const base = amount.token ? `${formatRawValue(amount.value)} ${amount.token}` : formatRawValue(amount.value) + + if (amount.isStreaming) { + return `${base} streaming` + } + + return base +} + +export function FundingLegend({ + projects, + colors, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + colors: string[] + onProjectPress?: (id: string) => void +}) { + if (projects.length === 0) { + return ( + + No active funding distribution yet. + + ) + } + + return ( + + {projects.map((project, index) => ( + + ))} + + ) +} + +export function FundingDonut({ + projects, + totalAmount, + colors, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + totalAmount: GovernanceAmount + colors: string[] + onProjectPress?: (id: string) => void +}) { + const size = 196 + const strokeWidth = 22 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + let offset = 0 + + return ( + + + + + {projects.map((project, index) => { + const percentage = clampPercentage(project.percentage) + const dashLength = (percentage / 100) * circumference + const dashOffset = -offset + offset += dashLength + + return ( + onProjectPress(project.id) : undefined} + /> + ) + })} + + + + {renderGovernanceAmount(totalAmount, 'md')} + + Total active funding + + + + ) +} diff --git a/packages/governance-widget/src/types.ts b/packages/governance-widget/src/types.ts new file mode 100644 index 0000000..c696ccb --- /dev/null +++ b/packages/governance-widget/src/types.ts @@ -0,0 +1,98 @@ +import type { IconName } from '@goodwidget/ui' + +export interface GovernanceAmount { + value: string | number + token?: string + isStreaming?: boolean + streamLabel?: string +} + +export interface ImpactCardMetric { + label: string + amount: GovernanceAmount + description?: string +} + +export interface ImpactCardProps { + title: string + metrics: [ImpactCardMetric, ImpactCardMetric] + description: string + ctaLabel?: string + ctaDisabled?: boolean + onCtaPress?: () => void + testID?: string +} + +export interface BalanceCardMetadata { + label: string + tone?: 'default' | 'positive' | 'muted' + icon?: IconName +} + +export interface BalanceCardProps { + icon: IconName + title: string + amount: GovernanceAmount | string | number + amountType?: 'token' | 'raw' + metadata: BalanceCardMetadata + compact?: boolean + testID?: string +} + +export interface RankedVotingOption { + id: string + label: string + percentage: number +} + +export interface AlignmentVotingProposalCardProps { + id: string + categoryLabel: string + title: string + summaryLabel?: string + options: RankedVotingOption[] + maxVisibleOptions?: number + onPress?: (id: string) => void + testID?: string +} + +export interface VoteSegment { + id: string + label: string + percentage: number + tone?: 'for' | 'against' | 'abstain' | 'neutral' +} + +export interface VoterPreview { + id: string + label: string + avatarUrl?: string +} + +export interface OptimisticVotingProposalCardProps { + id: string + categoryLabel: string + title: string + quorumLabel?: string + quorumReachedPercent: number + voteSegments: VoteSegment[] + voters: VoterPreview[] + remainingVoterCountLabel?: string + onPress?: (id: string) => void + testID?: string +} + +export interface FundingProjectAllocation { + id: string + name: string + amount: GovernanceAmount + percentage: number +} + +export interface FundingDistributionChartProps { + totalAmount: GovernanceAmount + projects: FundingProjectAllocation[] + isStreaming?: boolean + onProjectPress?: (id: string) => void + testID?: string +} diff --git a/packages/governance-widget/src/utils.tsx b/packages/governance-widget/src/utils.tsx new file mode 100644 index 0000000..912b8c3 --- /dev/null +++ b/packages/governance-widget/src/utils.tsx @@ -0,0 +1,60 @@ +import React from 'react' +import { Heading, Text, TokenAmount, YStack } from '@goodwidget/ui' +import type { useTheme } from 'tamagui' +import type { GovernanceAmount } from './types' + +export const SEGMENT_TONES = { + for: '$primary', + against: '$error', + abstain: '$placeholderColor', + neutral: '$success', +} as const + +export const DONUT_COLOR_KEYS = ['primary', 'success', 'warning', 'info', 'error'] as const + +export function clampPercentage(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(100, value)) +} + +export function formatRawValue(value: string | number): string { + if (typeof value === 'string') { + return value + } + + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value) +} + +export function isGovernanceAmount(value: GovernanceAmount | string | number): value is GovernanceAmount { + return typeof value === 'object' && value !== null && 'value' in value +} + +export function resolveThemeColor(theme: ReturnType, key: string, fallback: string): string { + const themeValue = theme[key as keyof typeof theme] + + if (themeValue && typeof themeValue === 'object' && 'val' in themeValue) { + return String(themeValue.val) + } + + return fallback +} + +export function renderGovernanceAmount(amount: GovernanceAmount, size: 'sm' | 'md' | 'lg' | 'xl' = 'lg') { + return ( + + {amount.token ? ( + + ) : ( + {formatRawValue(amount.value)} + )} + {amount.isStreaming ? ( + + {amount.streamLabel ?? 'Live stream'} + + ) : null} + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c1446a..2f0e35b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,12 @@ importers: react-dom: specifier: ^18.3.0 version: 18.3.1(react@18.3.1) + react-native: + specifier: 0.76.9 + version: 0.76.9(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(react@18.3.1) + react-native-web: + specifier: ^0.19.13 + version: 0.19.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tsup: specifier: ^8.4.0 version: 8.5.1(@swc/core@1.15.30)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3)