HextorUI
block

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-card

Source

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.

Dependencies

@hextor/viz-tokens@hextor/axes@hextor/radar-geometry