Files
zeekayandClaude Code dc6a264a2f Say the comparison table in design tokens, not inline styles
The lint caught a token I invented (--borderColor is a Tamagui theme key,
not a CSS variable) and four style props that had a component already
written for them. The measured/reported switch is now the same ToggleGroup
the pricing plans use, and the table is Text render="tr"/"td" the way
marketing/Comparison.tsx already does it.

Co-Authored-By: Claude Code <[email protected]>
2026-10-04 20:58:53 -07:00

159 lines
5.9 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Anchor, Box, Text, ToggleGroup, ToggleGroupItem, XStack, YStack } from '@hanzo/ui'
import type { Comparison, CompareRow } from '@/lib/compare'
/* A rung and its own leading, so one row cannot come out a pixel and a half
taller than the one beside it. */
const SMALL = { fontSize: '$2', lineHeight: '1.1607rem' } as const
/**
* The switch between what we ran and what was published.
*
* Measured is the default and reported is one click away, because the two are
* not the same kind of claim and a reader should never have to scan for which is
* which. Showing everything at once is what every comparison table does, and it
* is how a relayed number ends up looking as solid as a reproduced one.
*/
function Toggle({
showReported,
onChange,
measuredCount,
reportedCount,
}: {
showReported: boolean
onChange: (v: boolean) => void
measuredCount: number
reportedCount: number
}) {
return (
<XStack mb="$4" p={4} rounded={9999} bg="var(--surface-2)" self="flex-start">
<ToggleGroup
type="single"
value={showReported ? 'reported' : 'measured'}
onValueChange={(v) => onChange(v === 'reported')}
disableDeactivation
aria-label="Which rows to show"
>
<ToggleGroupItem value="measured" variant="outline" rounded="$10" borderColor="transparent" px="$4">
Measured ({measuredCount})
</ToggleGroupItem>
<ToggleGroupItem value="reported" variant="outline" rounded="$10" borderColor="transparent" px="$4">
+ Reported ({reportedCount})
</ToggleGroupItem>
</ToggleGroup>
</XStack>
)
}
/** A dated link to where a figure came from. */
function Ref({ href, date }: { href: string; date: string }) {
return (
<>
{' '}
<Anchor href={href} target="_blank" rel="noreferrer noopener" color="var(--muted-foreground)">
[{date}]
</Anchor>
</>
)
}
function Row({ row, comparison }: { row: CompareRow; comparison: Comparison }) {
return (
<Text render="tr" borderBottomWidth={1} borderColor="var(--border)" $platform-web={{ display: 'table-row' }}>
<Text render="th" py={12} pl={1} pr={16} text="left" fontWeight="500" {...SMALL} $platform-web={{ display: 'table-cell' }}>
{row.attribute}
</Text>
<Text render="td" py={12} pl={1} pr={16} {...SMALL} $platform-web={{ display: 'table-cell' }}>
{row.ours}
{row.source === 'measured' && row.oursRef ? <Ref {...row.oursRef} /> : null}
</Text>
{comparison.rivals.map((r) => (
<Text
key={r.id}
render="td"
py={12}
pl={1}
pr={16}
color="var(--muted-foreground)"
{...SMALL}
$platform-web={{ display: 'table-cell' }}
>
{row.theirs[r.id] ?? 'not published'}
{row.theirRefs?.[r.id] ? <Ref {...row.theirRefs[r.id]} /> : null}
</Text>
))}
<Text render="td" py={12} pl={1} pr={1} color="var(--muted-foreground)" {...SMALL} $platform-web={{ display: 'table-cell' }}>
{row.source === 'measured' ? 'we ran this' : 'they published this'}
</Text>
</Text>
)
}
export function CompareTable({ comparison }: { comparison: Comparison }) {
const measured = comparison.rows.filter((r) => r.source === 'measured')
const reported = comparison.rows.filter((r) => r.source === 'reported')
// A category with nothing measured yet opens on the reported rows rather than
// showing an empty table, and says so.
const [showReported, setShowReported] = useState(measured.length === 0)
const rows = showReported ? comparison.rows : measured
const notes = comparison.rows.filter((r) => r.note && (showReported || r.source === 'measured'))
return (
<YStack>
<Toggle
showReported={showReported}
onChange={setShowReported}
measuredCount={measured.length}
reportedCount={reported.length}
/>
{/* Four vendors plus the attribute and provenance columns stop being
readable under ~720px; the wrapper scrolls rather than crushing them. */}
<Box overflowX="auto">
<Text
render="table"
width="100%"
minW={720}
text="left"
{...SMALL}
$platform-web={{ display: 'table', borderCollapse: 'collapse' }}
>
<thead>
<Text render="tr" borderColor="var(--border)" $platform-web={{ display: 'table-row' }}>
<Text render="th" py={12} pl={1} pr={16} text="left" fontWeight="500" color="var(--muted-foreground)" {...SMALL} $platform-web={{ display: 'table-cell' }}>
{comparison.subject.name} vs {comparison.rivals.map((r) => r.name).join(', ')}
</Text>
<Text render="th" py={12} pl={1} pr={16} text="left" fontWeight="500" {...SMALL} $platform-web={{ display: 'table-cell' }}>
Hanzo {comparison.subject.name}
</Text>
{comparison.rivals.map((r) => (
<Text key={r.id} render="th" py={12} pl={1} pr={16} text="left" fontWeight="500" {...SMALL} $platform-web={{ display: 'table-cell' }}>
{r.name}
</Text>
))}
<Text render="th" py={12} pl={1} pr={1} text="left" fontWeight="500" color="var(--muted-foreground)" {...SMALL} $platform-web={{ display: 'table-cell' }}>
Provenance
</Text>
</Text>
</thead>
<tbody>
{rows.map((row) => (
<Row key={row.attribute} row={row} comparison={comparison} />
))}
</tbody>
</Text>
</Box>
{notes.length ? (
<YStack mt="$3" gap="$2">
{notes.map((r) => (
<Text render="p" display="block" key={r.attribute} fontSize="$2" lineHeight="1.25rem" color="var(--muted-foreground)">
<strong>{r.attribute}:</strong> {r.note}
</Text>
))}
</YStack>
) : null}
</YStack>
)
}