Skill Card
The shareable card: radar silhouette, headline score and mutant tally, laid out to stay legible from a 200px thumbnail to a 1080px export.
npx shadcn@latest add @hextor/skill-cardSource
import { radarLayout, radarRing } from "@/registry/hextor/lib/radar-geometry";
import { resolveLabels, type SkillCardLabels } from "@/registry/hextor/components/hextor/skill-card/labels";
import type { ShareRatio, SkillCardData } from "@/registry/hextor/components/hextor/skill-card/types";
/**
* The shareable Skill Card.
*
* BUILD-PLAN §2 lists "share card + public profile" as the ONLY acquisition
* channel in a B2C product, and §5 S12 recuts it to **two aspect ratios, not
* four**. Four meant four layouts to keep legible for two places a card has
* actually been observed to travel:
*
* wide 1.91:1 — the link-unfurl geometry (Discord, X, LINE preview)
* tall 4:5 — what a phone screenshot dropped into a group chat looks like
*
* ── Three constraints shape everything below ──
*
* 1. **It must read at thumbnail size.** In a chat this arrives ~200px wide
* before anyone taps it, so the card is laid out entirely in `cqw`
* (percentage-of-own-width) units inside a `container-type: inline-size`
* wrapper. Rendered at 1080px or at 200px it is the *same* card, scaled —
* there is no breakpoint at which the composition changes and no size at
* which text reflows into a different shape. The reading order is designed to
* survive the shrink: silhouette (recognisable at any size) → hero number
* (huge) → the mutant pips (a countable strip, still countable tiny) → the
* handle and wordmark, which only matter once you have leaned in.
*
* Intended size range: ~200px (chat thumbnail) through ~1080px (full-size
* share/download). Never convert this layout to viewport units or
* breakpoints — that would reintroduce exactly the size at which the
* composition changes that point 1 exists to rule out.
*
* 2. **No external anything.** No image, no font file, no network. The radar is
* inline SVG, the pips are divs, the type is the app's already-loaded
* `--font-sans`. A card that waits on a request screenshots half-rendered.
*
* 3. **Axis order and colour are frozen** (`@/registry/hextor/lib/axes`). The
* silhouette is only comparable between two people, or against your own from
* six months ago, if neither ever changes. Geometry therefore comes from
* `@/registry/hextor/lib/radar-geometry`, shared with the interactive radar,
* so the shape on the card is provably the same shape as the shape on the
* report.
*/
/**
* Per-ratio type scale, in `cqw`.
*
* Two explicit specs rather than one spec times a fudge factor. The first draft
* scaled every wide-card size by 1.55 for the tall card and **overflowed its own
* bottom edge** — the hero number was sliced in half and the pip strip fell off
* the card entirely, which the full-size preview hid and only the 208px thumbnail
* made obvious. A 4:5 card is 125cqw tall and a 1.91:1 card is 52cqw tall; those
* are different layout problems, not one problem at two zoom levels.
*
* The tall column budget, so the next edit can be checked rather than guessed:
*
* padding 2 × 5 = 10.0
* identity 5.2×1.25 + 3.8×1.3 = 11.4
* radar = 56.0
* hero 20 × 0.85 = 17.0
* pip strip 3 + 1 + 3.6×1.25 = 8.5
* wordmark 3.2 × 1.2 = 3.8
* 4 × gap 4 × 3 = 12.0
* ────────────────────────────────
* total = 118.7 of 125 available
*/
interface CardSpec {
aspect: string;
label: string;
pad: number;
gap: number;
radius: number;
radar: number;
name: number;
sub: number;
hero: number;
heroSub: number;
pip: number;
pipGap: number;
tq: number;
mark: number;
}
const SPEC: Record<ShareRatio, CardSpec> = {
wide: {
aspect: "1200 / 628",
label: "1.91:1",
pad: 4,
gap: 3.5,
radius: 2,
radar: 38,
name: 3.6,
sub: 2.7,
hero: 15,
heroSub: 3.4,
pip: 2.1,
pipGap: 0.7,
tq: 2.6,
mark: 2.4,
},
tall: {
aspect: "1080 / 1350",
label: "4:5",
pad: 5,
gap: 3,
radius: 3,
radar: 56,
name: 5.2,
sub: 3.8,
hero: 20,
heroSub: 4.6,
pip: 3,
pipGap: 1,
tq: 3.6,
mark: 3.2,
},
};
/** The layout spec for a ratio — dimensions plus its short identifier (not copy, see `labels.ts` for the prose caption a consumer shows next to it). */
export function ratioMeta(r: ShareRatio) {
return SPEC[r];
}
/** `cqw` = 1% of the card's own width. Every dimension on the card is one of these. */
const u = (n: number) => `${n}cqw`;
export function SkillCard({
data,
ratio,
className,
labels,
}: {
data: SkillCardData;
ratio: ShareRatio;
className?: string;
/** Overrides `defaultLabels` (Thai); pass nothing to keep them. */
labels?: Partial<SkillCardLabels>;
}) {
const s = SPEC[ratio];
const tall = ratio === "tall";
const copy = resolveLabels(labels);
return (
<div
className={className}
style={{ containerType: "inline-size", width: "100%" }}
>
<div
// Committed to the theme surface rather than a fixed brand colour, so the
// card someone screenshots matches the app they screenshotted it from.
style={{
aspectRatio: s.aspect,
background: "var(--card)",
color: "var(--card-foreground)",
border: "1px solid var(--border)",
borderRadius: u(s.radius),
padding: u(s.pad),
display: "flex",
flexDirection: tall ? "column" : "row",
alignItems: tall ? "stretch" : "center",
gap: u(s.gap),
overflow: "hidden",
fontFamily: "var(--font-sans)",
}}
>
{tall ? (
<>
<Identity data={data} s={s} copy={copy} />
<div
style={{
display: "flex",
flex: 1,
minHeight: 0,
alignItems: "center",
justifyContent: "center",
}}
>
<CardRadar data={data} width={u(s.radar)} copy={copy} />
</div>
<Hero data={data} s={s} align="center" copy={copy} />
<MutantStrip data={data} s={s} align="center" copy={copy} />
<Wordmark s={s} align="center" copy={copy} />
</>
) : (
<>
<CardRadar data={data} width={u(s.radar)} copy={copy} />
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: u(2.2),
}}
>
<Identity data={data} s={s} copy={copy} />
<Hero data={data} s={s} align="start" copy={copy} />
<MutantStrip data={data} s={s} align="start" copy={copy} />
<Wordmark s={s} align="start" copy={copy} />
</div>
</>
)}
</div>
</div>
);
}
// ───────────────────────────── card pieces ─────────────────────────────
const clip = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
} as const;
function Identity({
data,
s,
copy,
}: {
data: SkillCardData;
s: CardSpec;
copy: SkillCardLabels;
}) {
return (
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: u(s.name),
fontWeight: 600,
lineHeight: 1.25,
...clip,
}}
>
{/* A report shared by someone with a private profile carries no handle —
the API omits the owner rather than the card hiding it. */}
{data.handle ? `@${data.handle}` : copy.anonymousHandle}
</div>
<div
style={{
fontSize: u(s.sub),
lineHeight: 1.3,
color: "var(--muted-foreground)",
...clip,
}}
>
{data.subtitle}
</div>
</div>
);
}
function Hero({
data,
s,
align,
copy,
}: {
data: SkillCardData;
s: CardSpec;
align: "start" | "center";
copy: SkillCardLabels;
}) {
return (
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: align === "center" ? "center" : "flex-start",
gap: u(1.6),
}}
>
{/*
The hero figure. Proportional figures, not tabular — `tabular-nums` gives
every digit the width of a zero, which makes a number like 77 look loose
at display size. Exactly one hero per card.
*/}
<span
style={{
fontSize: u(s.hero),
fontWeight: 650,
lineHeight: 0.85,
letterSpacing: "-0.03em",
}}
>
{Math.round(data.total)}
</span>
<span
style={{
fontSize: u(s.heroSub),
color: "var(--muted-foreground)",
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
{/* The achievable maximum, not 100 — see the note in the report page. */}
/{data.scorableMax}
{data.percentile !== undefined && (
<span style={{ paddingLeft: u(1.4) }}>
{copy.percentilePrefix} {100 - data.percentile}%
</span>
)}
</span>
</div>
);
}
/**
* The differentiator, and the reason this card is not a LeetCode badge.
*
* One pip per planted mutant: filled = your tests caught it, hollow = a broken
* solution slipped past you. Filled-vs-hollow is a non-colour channel, so the
* strip stays countable in greyscale, under CVD, and at thumbnail size where the
* `7/10` beside it has stopped being legible. The hue is the test-quality axis'
* own frozen colour — not a status colour: caught/missed is coverage, not
* pass/fail, and the reserved verdict palette must never be borrowed for it.
*/
function MutantStrip({
data,
s,
align,
copy,
}: {
data: SkillCardData;
s: CardSpec;
align: "start" | "center";
copy: SkillCardLabels;
}) {
// Catalogues are 10-12 per challenge (BUILD-PLAN §1.2). The clamp is so an
// aggregate figure on a profile — where the count is every mutant ever seen —
// cannot render a 400-pip strip that overflows the card.
const pips = Math.max(0, Math.min(12, data.mutantsTotal));
const filled = pips
? Math.round((data.mutantsCaught / data.mutantsTotal) * pips)
: 0;
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: u(s.pipGap),
alignItems: align === "center" ? "center" : "flex-start",
}}
>
<div style={{ display: "flex", gap: u(s.pipGap) }}>
{Array.from({ length: pips }, (_, i) => {
const caught = i < filled;
return (
<span
key={i}
style={{
width: u(s.pip),
height: u(s.pip),
borderRadius: u(s.pip * 0.24),
background: caught ? "var(--axis-test-quality)" : "transparent",
border: caught
? "none"
: `${u(s.pip * 0.17)} solid color-mix(in oklab, var(--muted-foreground) 55%, transparent)`,
}}
/>
);
})}
</div>
<div style={{ fontSize: u(s.tq), lineHeight: 1.25, ...clip }}>
<span style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
{data.mutantsCaught}/{data.mutantsTotal}
</span>
<span style={{ color: "var(--muted-foreground)" }}> {copy.mutantCaption}</span>
</div>
</div>
);
}
function Wordmark({
s,
align,
copy,
}: {
s: CardSpec;
align: "start" | "center";
copy: SkillCardLabels;
}) {
return (
<div
style={{
fontSize: u(s.mark),
lineHeight: 1.2,
color: "var(--muted-foreground)",
letterSpacing: "0.02em",
textAlign: align === "center" ? "center" : "left",
}}
>
{copy.wordmark}
</div>
);
}
// ───────────────────────────── the silhouette ─────────────────────────────
/**
* A static radar, drawn in a 100×100 user-space box so it scales purely by its
* CSS width. No hover, no tooltip, no legend — a single series needs none, and
* the card is an image as far as its reader is concerned.
*
* Deliberately bolder than the report's radar: 1-unit strokes and 2.2-unit
* vertices read as ~4px and ~9px on a full-size card, which is what keeps the
* shape recognisable once a chat client scales it to a 200px thumbnail. A
* hairline grid would vanish entirely, so there are two rings instead of four.
*
* Axis identity here is carried by ANGLE, not colour: the order is frozen in
* `AXES`, so the twelve-o'clock spoke is correctness for every user forever.
* That is what makes the axis hues redundant decoration rather than the sole
* channel — which matters because in light mode three of the five fall under 3:1
* against the surface. Each vertex additionally carries its own percentage as a
* direct numeric label. Never colour alone.
*/
function CardRadar({
data,
width,
copy,
}: {
data: SkillCardData;
width: string;
copy: SkillCardLabels;
}) {
const geom = { size: 100, radius: 30, labelGap: 11 };
const { cx, cy, vertices, polygon, scoredCount, anyWithheld } = radarLayout(
data.axes,
geom,
);
return (
<svg
viewBox="0 0 100 100"
style={{ width, height: "auto", flexShrink: 0, overflow: "visible" }}
role="img"
aria-label={`Skill radar: ${vertices
.map((v) =>
v.withheld
? `${v.meta.labelEn} ${copy.axisWithheldAria}`
: `${v.meta.labelEn} ${Math.round(v.pct * 100)}%`,
)
.join(", ")}${anyWithheld ? ` (${copy.radarWithheldNote})` : ""}`}
>
{/* recessive grid, solid hairlines — two rings only, so they survive the shrink */}
{[0.5, 1].map((f) => (
<polygon
key={f}
points={radarRing(f, geom)}
fill="none"
stroke="var(--viz-grid)"
strokeWidth={0.6}
/>
))}
{vertices.map((v) => (
<line
key={v.meta.key}
x1={cx}
y1={cy}
x2={v.ox}
y2={v.oy}
stroke={v.withheld ? "var(--viz-ink-muted)" : "var(--viz-grid)"}
strokeWidth={0.6}
strokeDasharray={v.withheld ? "2 2" : undefined}
opacity={v.withheld ? 0.55 : 1}
/>
))}
{/* The series. Withheld axes are omitted from the polygon, never plotted at
zero — a shape pulled to the centre would blame the user for an axis we
declined to measure. Three vertices is the minimum that still encloses
an area; below that the polygon is dropped and the numbers carry it. */}
{scoredCount >= 3 && (
<polygon
points={polygon}
fill="color-mix(in oklab, var(--foreground) 13%, transparent)"
stroke="var(--foreground)"
strokeWidth={1}
strokeLinejoin="round"
/>
)}
{vertices.map(
(v) =>
!v.withheld && (
<circle
key={`v-${v.meta.key}`}
cx={v.x}
cy={v.y}
r={2.2}
fill={v.meta.cssVar}
// Surface ring, so overlapping vertices stay separate marks
stroke="var(--card)"
strokeWidth={0.9}
/>
),
)}
{/* Direct numeric labels, anchored middle at every vertex so none can
overflow the box on the left or right side. */}
{vertices.map((v) => (
<text
key={`t-${v.meta.key}`}
x={v.lx}
y={v.ly + 2}
textAnchor="middle"
fill={v.withheld ? "var(--viz-ink-muted)" : "var(--foreground)"}
style={{
fontSize: 6,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
}}
>
{v.withheld ? "—" : `${Math.round(v.pct * 100)}%`}
</text>
))}
</svg>
);
}
Docs
Laid out in container-query units (cqw), not viewport units or breakpoints, because the same card has to read as a thumbnail in a feed and as a full-size share image. Sizing it by the viewport would make the thumbnail unreadable in exactly the place people first see it. Intended range is roughly 200px to 1080px wide; test both ends when you change anything.
The silhouette comes from @hextor/radar-geometry, the same module the interactive radar uses. Do not fork it — a card whose shape differs from the report page is a card nobody can compare, which is the whole reason axis order is frozen.
Needs @hextor/viz-tokens: axis colour is read through the --axis-* custom properties.