chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CompetitorBrand } from '@/lib/compare/data'
|
||||
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
|
||||
|
||||
export interface BrandIconTileProps {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
/**
|
||||
* Whether `icon` already renders a full, self-contained brand-colored
|
||||
* square (e.g. a fetched app-store-style logo) rather than a bare
|
||||
* transparent glyph. See {@link CompetitorBrand.selfFramed}.
|
||||
*/
|
||||
selfFramed?: boolean
|
||||
/** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */
|
||||
className?: string
|
||||
/** Icon glyph size inside the tile, e.g. `size-4`. Ignored when `selfFramed`. */
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A rounded, bordered icon tile matching the platform's app-icon chrome
|
||||
* conventions (border radius, border token, background), so competitor brand
|
||||
* logos read as the same first-class "app icon" chrome as the rest of the
|
||||
* product, instead of a bare, unframed SVG floating in the layout.
|
||||
*
|
||||
* A self-framed logo (already a complete brand-colored square) fills the
|
||||
* tile edge-to-edge, clipped to the same rounded corners. Otherwise the icon
|
||||
* is a small transparent glyph centered on a plain bordered background.
|
||||
*/
|
||||
export function BrandIconTile({
|
||||
icon: Icon,
|
||||
selfFramed = false,
|
||||
className = 'size-8',
|
||||
iconClassName = 'size-4',
|
||||
}: BrandIconTileProps) {
|
||||
if (selfFramed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 overflow-hidden rounded-xl border border-[var(--border-1)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className='size-full' aria-hidden='true' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={iconClassName} aria-hidden='true' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SimIconTileProps {
|
||||
/** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rounded, bordered tile as {@link BrandIconTile}, but for Sim's own
|
||||
* wordmark. So "Sim" gets the identical icon-chip treatment as every
|
||||
* competitor it's compared against, instead of appearing as bare text.
|
||||
*/
|
||||
export function SimIconTile({ className = 'size-8' }: SimIconTileProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className='inline-flex scale-[0.6]'>
|
||||
<SimWordmark />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BrandIconTileProps, SimIconTileProps } from './brand-icon-tile'
|
||||
export { BrandIconTile, SimIconTile } from './brand-icon-tile'
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FactSource } from '@/lib/compare/data'
|
||||
import { SourceLink } from '@/app/(landing)/comparison/components/source-info'
|
||||
|
||||
interface ComparisonCardItem {
|
||||
title: string
|
||||
description: string
|
||||
shortDescription?: string
|
||||
source: FactSource
|
||||
}
|
||||
|
||||
export interface ComparisonCardsProps {
|
||||
items: ComparisonCardItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertically stacked list of atomic, independently quotable fact cards,
|
||||
* each self-contained title + a one-line `shortDescription` (falling back to
|
||||
* `description` if a short version hasn't been authored yet). Used for both
|
||||
* a competitor's standout features and its documented limitations.
|
||||
*
|
||||
* The full `description` is always present as `sr-only` text. Server
|
||||
* rendered regardless of hover/JS state. So an LLM or crawler reading the
|
||||
* page still gets the complete claim even though a human sees only the
|
||||
* one-line summary. Hovering the title itself (`SourceLink`) shows a short
|
||||
* "Source: X" tooltip and clicking it opens the source, rather than a
|
||||
* separate info-icon affordance next to every card.
|
||||
*/
|
||||
export function ComparisonCards({ items }: ComparisonCardsProps) {
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={index > 0 ? 'border-[var(--border)] border-t px-6 py-4' : 'px-6 py-4'}
|
||||
>
|
||||
<h3 className='mb-1 text-[var(--text-primary)] text-base leading-snug tracking-[-0.01em]'>
|
||||
<SourceLink source={item.source}>{item.title}</SourceLink>
|
||||
</h3>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{item.shortDescription ?? item.description}
|
||||
</p>
|
||||
{item.shortDescription ? <span className='sr-only'>{item.description}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ComparisonCardsProps } from './comparison-cards'
|
||||
export { ComparisonCards } from './comparison-cards'
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CompetitorProfile } from '@/lib/compare/data'
|
||||
import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections'
|
||||
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { FactValue } from '@/app/(landing)/comparison/components/fact-value'
|
||||
|
||||
export interface ComparisonTableProps {
|
||||
sim: CompetitorProfile
|
||||
competitor: CompetitorProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the row-label column during horizontal scroll on genuinely spacious
|
||||
* viewports (the standard pattern for responsive data tables, e.g.
|
||||
* Stripe/GitHub/Notion comparison tables) so a reader keeps row context while
|
||||
* scrolling to see the Sim/competitor values. Below `lg` (this page's own
|
||||
* tablet-and-below tier, per `.claude/rules` for this route group) the table
|
||||
* switches to a stacked layout instead (see `MOBILE_STACK_*`) rather than
|
||||
* relying on horizontal scroll at a width too narrow to render a 3-column
|
||||
* table comfortably, so sticky positioning is scoped to `lg:` only. The
|
||||
* shadow is a permanent CSS-only affordance (no scroll-position JS) so this
|
||||
* stays a zero-hydration server component.
|
||||
*/
|
||||
const STICKY_LABEL_COL = 'lg:sticky lg:left-0 lg:z-10 lg:shadow-[2px_0_4px_-2px_rgba(0,0,0,0.08)]'
|
||||
|
||||
/**
|
||||
* Below `lg` (1024px) a 3-column grid doesn't reliably have room to be
|
||||
* legible, so each fact instead stacks as label -> Sim's value -> the
|
||||
* competitor's value, with a small name tag on each value since the column
|
||||
* headers are no longer directly above. Applied to the label cell.
|
||||
*/
|
||||
const MOBILE_STACK_LABEL = 'max-lg:border-r-0 max-lg:border-b-0 max-lg:pt-3 max-lg:pb-1'
|
||||
|
||||
/**
|
||||
* Applied to a value cell (Sim's or the competitor's) in the stacked mobile
|
||||
* layout. `items-stretch` overrides the cell's base `items-center` (which
|
||||
* would otherwise shrink-wrap and center each child horizontally in a
|
||||
* flex-col): stretching gives the name tag and the value their own
|
||||
* full-width box to left-align and truncate within, instead of a
|
||||
* content-sized box with no boundary to clip against.
|
||||
*/
|
||||
const MOBILE_STACK_VALUE =
|
||||
'max-lg:flex-col max-lg:items-stretch max-lg:gap-0.5 max-lg:border-r-0 max-lg:px-4'
|
||||
|
||||
function ColumnHeader({
|
||||
name,
|
||||
iconTile,
|
||||
isSim,
|
||||
}: {
|
||||
name: string
|
||||
iconTile: ReactNode
|
||||
isSim: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col items-center gap-2 border-[var(--border-1)] border-b px-3 py-4 text-center',
|
||||
isSim ? 'bg-[var(--surface-2)]' : 'bg-[var(--surface-1)]'
|
||||
)}
|
||||
>
|
||||
{iconTile}
|
||||
<span className='w-full truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column "Sim vs {Competitor}" fact table, styled after the billing
|
||||
* upgrade-page comparison table (same border/hairline rhythm and section
|
||||
* headers) but data-driven off {@link CompetitorProfile.facts} instead of the
|
||||
* fixed 4-tier plan schema. Data cells share one neutral surface for both
|
||||
* columns. The Sim column is called out only in the header row (a bottom
|
||||
* accent border), so the table reads as one clean grid rather than a
|
||||
* checkerboard. Pure server component: every value is plain server-rendered
|
||||
* text so crawlers and AI answer engines read the full comparison without
|
||||
* any client-side hydration.
|
||||
*/
|
||||
export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
|
||||
return (
|
||||
<div className='w-full overflow-x-auto rounded-xl border border-[var(--border-1)]'>
|
||||
<div
|
||||
role='table'
|
||||
aria-label={`Sim vs ${competitor.name} feature comparison`}
|
||||
className='grid grid-cols-1 lg:min-w-[560px] lg:grid-cols-[minmax(140px,max-content)_1fr_1fr]'
|
||||
>
|
||||
<div className='contents' role='row'>
|
||||
<div
|
||||
role='columnheader'
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col justify-center border-[var(--border)] border-r border-b bg-[var(--surface-1)] px-4 py-4',
|
||||
STICKY_LABEL_COL,
|
||||
'max-lg:border-r-0'
|
||||
)}
|
||||
>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
Compare
|
||||
</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-small'>
|
||||
{sim.name} vs {competitor.name}
|
||||
</span>
|
||||
</div>
|
||||
<ColumnHeader name={sim.name} iconTile={<SimIconTile className='size-9' />} isSim />
|
||||
<ColumnHeader
|
||||
name={competitor.name}
|
||||
iconTile={
|
||||
competitor.brand?.icon ? (
|
||||
<BrandIconTile
|
||||
icon={competitor.brand.icon}
|
||||
selfFramed={competitor.brand.selfFramed}
|
||||
className='size-9'
|
||||
iconClassName='size-5'
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
isSim={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{COMPARISON_SECTIONS.map((section, sectionIdx) => {
|
||||
const simGroupFacts = getFactGroup(sim, section.group)
|
||||
const competitorGroupFacts = getFactGroup(competitor, section.group)
|
||||
|
||||
return (
|
||||
<div key={section.title} className='contents'>
|
||||
<div className='contents' role='row'>
|
||||
<div
|
||||
role='columnheader'
|
||||
className={cn(
|
||||
'border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2',
|
||||
STICKY_LABEL_COL,
|
||||
'max-lg:border-r-0',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role='presentation'
|
||||
className={cn(
|
||||
'col-span-2 bg-[var(--surface-1)] max-lg:hidden',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{section.rows.map((row, rowIdx) => {
|
||||
const simFact = simGroupFacts[row.key]
|
||||
const competitorFact = competitorGroupFacts[row.key]
|
||||
const isNotLastRow = rowIdx < section.rows.length - 1
|
||||
|
||||
return (
|
||||
<div key={row.key} className='contents' role='row'>
|
||||
<div
|
||||
role='rowheader'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2.5',
|
||||
STICKY_LABEL_COL,
|
||||
MOBILE_STACK_LABEL,
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='text-[var(--text-body)] text-small max-lg:font-medium max-lg:text-[var(--text-primary)]'>
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role='cell'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 border-[var(--border)] border-r bg-[var(--surface-2)] px-3 py-2.5',
|
||||
MOBILE_STACK_VALUE,
|
||||
'max-lg:border-b-0 max-lg:pt-1 max-lg:pb-1',
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-muted)] text-caption lg:hidden'>
|
||||
{sim.name}
|
||||
</span>
|
||||
<FactValue fact={simFact} />
|
||||
</div>
|
||||
<div
|
||||
role='cell'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 bg-[var(--surface-2)] px-3 py-2.5',
|
||||
MOBILE_STACK_VALUE,
|
||||
'max-lg:pt-1 max-lg:pb-3',
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-muted)] text-caption lg:hidden'>
|
||||
{competitor.name}
|
||||
</span>
|
||||
<FactValue fact={competitorFact} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ComparisonTableProps } from './comparison-table'
|
||||
export { ComparisonTable } from './comparison-table'
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Check, X } from '@sim/emcn/icons'
|
||||
import type { Fact } from '@/lib/compare/data'
|
||||
import { SourceLink } from '@/app/(landing)/comparison/components/source-info'
|
||||
import { parseFactValue } from '@/app/(landing)/comparison/fact-status'
|
||||
|
||||
export interface FactValueProps {
|
||||
fact: Fact
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one {@link Fact} for a glancing reader while keeping the full
|
||||
* granular fact server-rendered for crawlers and AI answer engines.
|
||||
*
|
||||
* - A true "Yes"/"No" fact renders as an icon alone (a monochrome check or
|
||||
* muted cross, no colored pass/fail styling), no visible text, since the
|
||||
* label column and surrounding context already say what's being asked.
|
||||
* - Any other fact shows its `shortValue` (a compact, pre-authored
|
||||
* restatement of `value`), never the full sentence.
|
||||
* - `Tooltip` here is a cursor-following mini-bubble meant for a short
|
||||
* one-line label (see its own docs/usages: "Refresh", "last updated: X")
|
||||
* . It is deliberately NOT used to hold paragraph-length detail text, only
|
||||
* the compact source citation, which is exactly what it's designed for.
|
||||
* - When a source exists, the visible glance (icon or `shortValue` text)
|
||||
* IS the hover/click target for that source, via `SourceLink`, rather
|
||||
* than a separate info-icon next to every value. One affordance per
|
||||
* fact keeps a 58-row table from reading as icon-cluttered.
|
||||
* - A `sr-only` span always carries the complete value, detail, and source
|
||||
* in the initial server-rendered HTML, independent of hover/JS state, so
|
||||
* an LLM or crawler reading the page gets full granularity even though a
|
||||
* human sees only the compact glance.
|
||||
*/
|
||||
export function FactValue({ fact }: FactValueProps) {
|
||||
const { status, text } = parseFactValue(fact.value)
|
||||
const isBoolean = status === 'yes' || status === 'no'
|
||||
const primarySource = fact.sources[0]
|
||||
|
||||
const fullText = [fact.value, fact.detail].filter(Boolean).join('. ')
|
||||
|
||||
const glance = isBoolean ? (
|
||||
status === 'yes' ? (
|
||||
<Check className='size-[14px] shrink-0 text-[var(--text-primary)]' aria-hidden='true' />
|
||||
) : (
|
||||
<X className='size-[14px] shrink-0 text-[var(--text-muted)]' aria-hidden='true' />
|
||||
)
|
||||
) : null
|
||||
|
||||
// A pure yes/no fact renders as an icon only. The "why" lives in the
|
||||
// source link and the sr-only text, not cluttering the glance view.
|
||||
const shortText = isBoolean ? null : (fact.shortValue ?? text)
|
||||
|
||||
const valueNode = glance ?? (
|
||||
<span className='truncate text-[var(--text-body)] text-small'>{shortText}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
{primarySource ? (
|
||||
<SourceLink source={primarySource} className={glance ? 'shrink-0' : 'min-w-0 truncate'}>
|
||||
{valueNode}
|
||||
</SourceLink>
|
||||
) : (
|
||||
valueNode
|
||||
)}
|
||||
<span className='sr-only'>{fullText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { FactValueProps } from './fact-value'
|
||||
export { FactValue } from './fact-value'
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { SourceLinkProps } from './source-info'
|
||||
export { SourceLink } from './source-info'
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn, Tooltip } from '@sim/emcn'
|
||||
import type { FactSource } from '@/lib/compare/data'
|
||||
|
||||
export interface SourceLinkProps {
|
||||
source: FactSource
|
||||
children: ReactNode
|
||||
/** Additional classes for the trigger element (the visible value/title). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a fact's visible value (or a card's title) so hovering it directly
|
||||
* shows a one-line "Source: X" tooltip, and clicking it opens the source,
|
||||
* rather than a separate info-icon affordance next to every value. One
|
||||
* hover/click target per fact instead of two keeps the dense comparison
|
||||
* table and card lists from reading as icon-cluttered. Every {@link FactSource}
|
||||
* carries a real, publicly reachable URL (enforced by the type), so this
|
||||
* always renders as a link.
|
||||
*/
|
||||
export function SourceLink({ source, children, className }: SourceLinkProps) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label={`${source.label} (opens source)`}
|
||||
className={cn('block min-w-0', className)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Source: {source.label}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user