@haruhimemoe/ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.0] - 2026-10-04
10
+
11
+ ### Added
12
+
13
+ - `PlayerCard`: osu!-web's user card from plain props (cover, avatar, country and team flags, supporter heart, username linking to the profile, an optional status row). It never fetches; apps pass a snapshot.
14
+
9
15
  ## [0.9.0] - 2026-10-03
10
16
 
11
17
  ### Added
@@ -138,7 +144,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
138
144
  - `className` on every component, and the extras passed to `buttonClasses` and `fieldClasses`, merge with tailwind-merge: a caller's class replaces a built-in one that sets the same property (`fieldClasses("w-auto")` drops `w-full`).
139
145
  - Shell: `SiteHeader` (brand slot, nav links as data with `aria-current`, actions slot), `NavLinks`, `SiteFooter` (link columns as data, fine print, the haruhime.moe wordmark and a GitHub link) and `PageShell` (skip link, header, main, footer).
140
146
 
141
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.9.0...HEAD
147
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.10.0...HEAD
148
+ [0.10.0]: https://github.com/haruhimemoe/ui/compare/v0.9.0...v0.10.0
142
149
  [0.9.0]: https://github.com/haruhimemoe/ui/compare/v0.8.0...v0.9.0
143
150
  [0.8.0]: https://github.com/haruhimemoe/ui/compare/v0.7.0...v0.8.0
144
151
  [0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @haruhimemoe/ui
4
4
 
5
- React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces, a command palette (mod+k, with the defaults every tool shares) and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
5
+ React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces and player cards, a command palette (mod+k, with the defaults every tool shares) and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
6
6
 
7
7
  See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
8
8
 
@@ -814,7 +814,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
814
814
 
815
815
  ### osu!
816
816
 
817
- Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (no osu! API types) and are Server Components.
817
+ Since 0.4.0. Display pieces for beatmaps, mod pools and (since 0.10.0) players. They take plain values (no osu! API types) and are Server Components.
818
818
 
819
819
  #### `StarRating`
820
820
 
@@ -845,6 +845,42 @@ A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: N
845
845
  | --- | --- | --- | --- |
846
846
  | `mod` | `string` | required | The mod or slot label. |
847
847
 
848
+ #### `PlayerCard`
849
+
850
+ Since 0.10.0. osu!-web's user card (the 120px card from the friends list and user tooltips): the profile cover under a dark overlay, the 60px avatar, the country flag, the team flag and the supporter heart, the username, and an optional status row. The whole card links to the osu! profile. Every native `<div>` prop except `children`.
851
+
852
+ It never fetches. Pass a snapshot you keep yourself (from one osu! API lookup, or typed by hand), so a page of cards costs no API calls and hits no rate limits. Leave `status` out for static data: the card draws no online or offline ring unless told to. Images are plain `<img>` tags (lazy; the avatar and flags are sized), so the app needs no `images.remotePatterns` for osu!'s hosts. A cover ending in `.gif` is hidden when the visitor asks for reduced motion.
853
+
854
+ ```tsx
855
+ <PlayerCard
856
+ username="peppy"
857
+ userId={2}
858
+ countryCode="AU"
859
+ coverUrl="https://assets.ppy.sh/user-profile-covers/2/….jpeg"
860
+ team={{ name: "mom?", flagUrl: "https://assets.ppy.sh/teams/flag/1/….png" }}
861
+ supporter
862
+ statusText="osu!"
863
+ />
864
+ ```
865
+
866
+ | Prop | Type | Default | What it does |
867
+ | --- | --- | --- | --- |
868
+ | `username` | `string` | required | The name on the card. |
869
+ | `userId` | `number` | none | The osu! id: the avatar comes from `https://a.ppy.sh/<id>` and the card links to `https://osu.ppy.sh/users/<id>`. |
870
+ | `href` | `string \| null` | the profile | Replaces the link; `null` draws none. Without a link the name is plain text. A linked card outlines on hover and on keyboard focus. |
871
+ | `avatarUrl` | `string` | from `userId` | Replaces the avatar. With neither, the name's first letter stands in. |
872
+ | `coverUrl` | `string` | none | The cover behind the card. None leaves it plain `b4`. |
873
+ | `countryCode` | `string` | none | ISO 3166-1 alpha-2. Draws osu!'s own flag (`osu.ppy.sh/assets/images/flags/<code points>.svg`); anything but two letters draws none. |
874
+ | `countryName` | `string` | English name from `Intl` | The flag's alt text. |
875
+ | `team` | `{ name, flagUrl }` | none | The team flag beside the country's, its alt text the team's name. |
876
+ | `supporter` | `boolean` | `false` | Draws the supporter heart. |
877
+ | `supporterLabel` | `string` | `"osu! supporter"` | What screen readers hear for the heart. |
878
+ | `status` | `"online" \| "offline"` | none | Draws the status ring (lime online, dark offline). |
879
+ | `statusText` | `ReactNode` | `"Online"`/`"Offline"` with a status | The bottom row's main line: a status, or anything short (a role). |
880
+ | `statusNote` | `ReactNode` | none | A small line above it ("Last seen 29 days ago", "formerly RMarc"). |
881
+
882
+ The bottom row is left out when there is no status, text or note; the card keeps its 120px height so cards line up in a grid. A card with a cover gets a `b5` overlay at 80%, enough for `c2` text at 12px to keep 4.5:1 over a white cover (axe can't check text over an image).
883
+
848
884
  ### MDX (server)
849
885
 
850
886
  Since 0.9.0. Three subpaths, so an app that never renders Markdown loads none of this: `@haruhimemoe/ui/mdx` (the React pieces: `mdxComponents`, `CodeBlock`, `Callout`), `@haruhimemoe/ui/remark` (plain functions, no React, for `@next/mdx` and `react-markdown`'s `remarkPlugins`), and `@haruhimemoe/ui/shiki` (opt-in code highlighting). The root `@haruhimemoe/ui` export is unchanged.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @file src/components/osu/PlayerCard.tsx
3
+ * @desc osu!-web's user card (the 120px card from the friends list and user tooltips) from plain
4
+ * props: cover under a b5 overlay, 60px avatar, country flag, team flag and supporter heart,
5
+ * the username (the whole card links to the profile), and an optional status row. It never
6
+ * fetches: the app passes a snapshot, so nothing here claims live data unless told to. The
7
+ * online ring's lime is osu!'s own green-light, hue-independent like StarRating's spectrum.
8
+ * Keyboard focus outlines the whole card in h1 as well as the username's own ring.
9
+ * Plain `<img>`s, not next/image, so apps need no `images.remotePatterns` for osu!'s hosts.
10
+ * Server-safe.
11
+ * @author David @dvhsh (https://dvh.sh)
12
+ * @created Sun Oct 4, 2026
13
+ * @modified Sun Oct 4, 2026
14
+ */
15
+ import type { ComponentProps, ReactNode } from "react";
16
+ /** An osu! team: its name (the flag's alt text and tooltip) and its flag image. */
17
+ export type PlayerTeam = {
18
+ /** The team's name. */
19
+ name: string;
20
+ /** The team flag's URL (osu! serves them from assets.ppy.sh). */
21
+ flagUrl: string;
22
+ };
23
+ /** Every native `<div>` prop except children, plus the player's snapshot. */
24
+ export type PlayerCardProps = Omit<ComponentProps<"div">, "children"> & {
25
+ /** The name shown on the card, as-is. */
26
+ username: string;
27
+ /** The osu! user id: the avatar comes from a.ppy.sh and the card links to the profile. */
28
+ userId?: number | undefined;
29
+ /** Replaces the profile link; `null` draws no link even with a `userId`. */
30
+ href?: string | null | undefined;
31
+ /** Replaces the a.ppy.sh avatar. Without it and a `userId`, a letter stands in. */
32
+ avatarUrl?: string | undefined;
33
+ /** The profile cover, drawn behind everything. None leaves the card plain b4. */
34
+ coverUrl?: string | undefined;
35
+ /** ISO 3166-1 alpha-2 code; draws osu!'s flag. Anything else draws none. */
36
+ countryCode?: string | undefined;
37
+ /** The flag's alt text (default: the English name from Intl, else the code). */
38
+ countryName?: string | undefined;
39
+ /** The player's osu! team, drawn as its flag beside the country's. */
40
+ team?: PlayerTeam | undefined;
41
+ /** Draws the supporter heart. */
42
+ supporter?: boolean | undefined;
43
+ /** What screen readers hear for the heart (default "osu! supporter"). */
44
+ supporterLabel?: string | undefined;
45
+ /** Draws the status ring. Leave it out for static data: the card never guesses a status. */
46
+ status?: "online" | "offline" | undefined;
47
+ /** The bottom row's main line (default "Online" or "Offline" when `status` is set). */
48
+ statusText?: ReactNode;
49
+ /** A small line above it ("Last seen 29 days ago", "formerly RMarc"). */
50
+ statusNote?: ReactNode;
51
+ };
52
+ /**
53
+ * @function PlayerCard
54
+ * @param props {PlayerCardProps} the player's snapshot (name, id, cover, flags, supporter,
55
+ * status) and native div props
56
+ * @returns {JSX.Element} a 120px osu!-web user card; the whole card links to the profile when it
57
+ * has a link
58
+ */
59
+ export declare function PlayerCard({ username, userId, href, avatarUrl, coverUrl, countryCode, countryName, team, supporter, supporterLabel, status, statusText, statusNote, className, ...props }: PlayerCardProps): import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ import { AutoLink } from "../basics/AutoLink.js";
4
+ import { avatarUrl as defaultAvatarUrl, countryName as defaultCountryName, flagUrl, isAnimatedImage, normalizeCountryCode, profileUrl, } from "./playerLinks.js";
5
+ /** The first letter or digit of a name, for the stand-in avatar. */
6
+ function initialOf(name) {
7
+ return name.match(/[\p{L}\p{N}]/u)?.[0]?.toUpperCase() ?? "?";
8
+ }
9
+ /** Whether a status line has something to show: not null, undefined, a boolean or "". */
10
+ function shown(node) {
11
+ return node != null && typeof node !== "boolean" && node !== "";
12
+ }
13
+ /** The supporter heart: osu!'s pink-circle badge with a white heart. */
14
+ function SupporterHeart({ label }) {
15
+ return (_jsx("span", { role: "img", "aria-label": label, className: "inline-flex size-[26px] shrink-0 items-center justify-center rounded-full bg-h2 text-c1", children: _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", className: "size-3.5", fill: "currentColor", children: _jsx("path", { d: "M12 21s-7.5-4.6-10-9.3C.3 8.4 2.2 4.5 6 4.5c2.2 0 3.6 1.2 4.5 2.5l1.5 2 1.5-2c.9-1.3 2.3-2.5 4.5-2.5 3.8 0 5.7 3.9 4 7.2C19.5 16.4 12 21 12 21z" }) }) }));
16
+ }
17
+ /**
18
+ * @function PlayerCard
19
+ * @param props {PlayerCardProps} the player's snapshot (name, id, cover, flags, supporter,
20
+ * status) and native div props
21
+ * @returns {JSX.Element} a 120px osu!-web user card; the whole card links to the profile when it
22
+ * has a link
23
+ */
24
+ export function PlayerCard({ username, userId, href, avatarUrl, coverUrl, countryCode, countryName, team, supporter = false, supporterLabel = "osu! supporter", status, statusText, statusNote, className, ...props }) {
25
+ const link = href === null ? null : (href ?? (userId === undefined ? null : profileUrl(userId)));
26
+ const avatar = avatarUrl ?? (userId === undefined ? null : defaultAvatarUrl(userId));
27
+ const code = normalizeCountryCode(countryCode);
28
+ const flag = flagUrl(countryCode);
29
+ const mainLine = shown(statusText)
30
+ ? statusText
31
+ : status === "online"
32
+ ? "Online"
33
+ : status
34
+ ? "Offline"
35
+ : null;
36
+ const note = shown(statusNote) ? statusNote : null;
37
+ const hasStatusRow = status !== undefined || mainLine !== null || note !== null;
38
+ return (_jsxs("div", { className: cx("relative isolate flex h-[120px] flex-col justify-between overflow-hidden rounded-[10px] bg-b4 text-c1", link &&
39
+ "hover:outline-2 hover:outline-c3 has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-h1", className), ...props, children: [coverUrl ? (_jsx("img", { src: coverUrl, alt: "", loading: "lazy", decoding: "async", className: cx("absolute inset-0 -z-10 size-full object-cover", isAnimatedImage(coverUrl) && "motion-reduce:hidden") })) : null, coverUrl ? _jsx("div", { "aria-hidden": "true", className: "absolute inset-0 -z-10 bg-b5/80" }) : null, _jsxs("div", { className: "flex gap-2.5 p-2.5", children: [avatar ? (_jsx("img", { src: avatar, alt: "", width: 60, height: 60, loading: "lazy", decoding: "async", className: "size-[60px] shrink-0 rounded-md bg-b4 object-cover" })) : (_jsx("span", { "aria-hidden": "true", className: "flex size-[60px] shrink-0 items-center justify-center rounded-md bg-b3 font-bold text-2xl text-c3", children: initialOf(username) })), _jsxs("div", { className: "grid min-w-0 flex-1 grid-rows-[26px_1fr]", children: [_jsxs("div", { className: "flex h-[26px] items-center gap-1.5", children: [flag && code ? (_jsx("img", { src: flag, alt: countryName ?? defaultCountryName(code), width: 36, height: 26, loading: "lazy", decoding: "async", className: "h-[26px] w-auto shrink-0" })) : null, team ? (_jsx("img", { src: team.flagUrl, alt: team.name, width: 52, height: 26, loading: "lazy", decoding: "async", className: "h-[26px] w-[52px] shrink-0 rounded-sm object-cover" })) : null, supporter ? _jsx(SupporterHeart, { label: supporterLabel }) : null] }), _jsx("div", { className: "flex min-w-0 items-center", children: link ? (_jsx(AutoLink, { href: link, className: "truncate font-semibold text-base text-c1 after:absolute after:inset-0", children: username })) : (_jsx("span", { className: "truncate font-semibold text-base", children: username })) })] })] }), hasStatusRow ? (_jsxs("div", { className: "flex items-center gap-2.5 px-2.5 pb-2.5", children: [_jsx("div", { className: "flex w-[60px] shrink-0 justify-center", children: status ? (_jsx("span", { "aria-hidden": "true", className: cx("size-[25px] rounded-full border-4", status === "online" ? "border-lime-400" : "border-b6") })) : null }), _jsxs("div", { className: "flex min-w-0 flex-col", children: [note !== null ? _jsx("span", { className: "truncate text-c2 text-xs", children: note }) : null, mainLine !== null ? _jsx("span", { className: "truncate text-sm", children: mainLine }) : null] })] })) : null] }));
40
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @file src/components/osu/playerLinks.ts
3
+ * @desc The osu! URLs and names PlayerCard builds from plain props: profile and avatar from a
4
+ * user id, osu-web's own country flag SVG from a two-letter code, the country's English
5
+ * name for the flag's alt text, and whether a cover is an animated GIF. Pure, server-safe.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sun Oct 4, 2026
8
+ * @modified Sun Oct 4, 2026
9
+ */
10
+ /**
11
+ * @function profileUrl
12
+ * @param id {number} the osu! user id
13
+ * @returns {string} the user's osu! profile page
14
+ */
15
+ export declare const profileUrl: (id: number) => string;
16
+ /**
17
+ * @function avatarUrl
18
+ * @param id {number} the osu! user id
19
+ * @returns {string} the user's current avatar on a.ppy.sh (osu! serves the default for none)
20
+ */
21
+ export declare const avatarUrl: (id: number) => string;
22
+ /**
23
+ * @function normalizeCountryCode
24
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code, any case, maybe padded
25
+ * @returns {string | null} the code in capitals, or null when it isn't two ASCII letters
26
+ */
27
+ export declare function normalizeCountryCode(code: string | undefined): string | null;
28
+ /**
29
+ * @function flagUrl
30
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code
31
+ * @returns {string | null} osu-web's flag SVG (named by the flag emoji's regional-indicator code
32
+ * points, lowercase hex joined by "-"), or null for anything but two ASCII letters
33
+ */
34
+ export declare function flagUrl(code: string | undefined): string | null;
35
+ /**
36
+ * @function countryName
37
+ * @param code {string} an ISO 3166-1 alpha-2 code
38
+ * @returns {string} the region's English name from Intl, or the code in capitals when Intl
39
+ * doesn't know it
40
+ */
41
+ export declare function countryName(code: string): string;
42
+ /**
43
+ * @function isAnimatedImage
44
+ * @param url {string} an image URL
45
+ * @returns {boolean} true when the path ends in .gif (before any query or hash)
46
+ */
47
+ export declare const isAnimatedImage: (url: string) => boolean;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file src/components/osu/playerLinks.ts
3
+ * @desc The osu! URLs and names PlayerCard builds from plain props: profile and avatar from a
4
+ * user id, osu-web's own country flag SVG from a two-letter code, the country's English
5
+ * name for the flag's alt text, and whether a cover is an animated GIF. Pure, server-safe.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sun Oct 4, 2026
8
+ * @modified Sun Oct 4, 2026
9
+ */
10
+ const REGIONAL_INDICATOR_A = 0x1f1e6;
11
+ /**
12
+ * @function profileUrl
13
+ * @param id {number} the osu! user id
14
+ * @returns {string} the user's osu! profile page
15
+ */
16
+ export const profileUrl = (id) => `https://osu.ppy.sh/users/${id}`;
17
+ /**
18
+ * @function avatarUrl
19
+ * @param id {number} the osu! user id
20
+ * @returns {string} the user's current avatar on a.ppy.sh (osu! serves the default for none)
21
+ */
22
+ export const avatarUrl = (id) => `https://a.ppy.sh/${id}`;
23
+ /**
24
+ * @function normalizeCountryCode
25
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code, any case, maybe padded
26
+ * @returns {string | null} the code in capitals, or null when it isn't two ASCII letters
27
+ */
28
+ export function normalizeCountryCode(code) {
29
+ const upper = code?.trim().toUpperCase() ?? "";
30
+ return /^[A-Z]{2}$/.test(upper) ? upper : null;
31
+ }
32
+ /**
33
+ * @function flagUrl
34
+ * @param code {string | undefined} an ISO 3166-1 alpha-2 code
35
+ * @returns {string | null} osu-web's flag SVG (named by the flag emoji's regional-indicator code
36
+ * points, lowercase hex joined by "-"), or null for anything but two ASCII letters
37
+ */
38
+ export function flagUrl(code) {
39
+ const upper = normalizeCountryCode(code);
40
+ if (!upper)
41
+ return null;
42
+ const points = [...upper].map((letter) => (REGIONAL_INDICATOR_A + letter.charCodeAt(0) - 65).toString(16));
43
+ return `https://osu.ppy.sh/assets/images/flags/${points.join("-")}.svg`;
44
+ }
45
+ /**
46
+ * @function countryName
47
+ * @param code {string} an ISO 3166-1 alpha-2 code
48
+ * @returns {string} the region's English name from Intl, or the code in capitals when Intl
49
+ * doesn't know it
50
+ */
51
+ export function countryName(code) {
52
+ const upper = code.trim().toUpperCase();
53
+ try {
54
+ return new Intl.DisplayNames(["en"], { type: "region" }).of(upper) ?? upper;
55
+ }
56
+ catch {
57
+ return upper;
58
+ }
59
+ }
60
+ /**
61
+ * @function isAnimatedImage
62
+ * @param url {string} an image URL
63
+ * @returns {boolean} true when the path ends in .gif (before any query or hash)
64
+ */
65
+ export const isAnimatedImage = (url) => /\.gif(?:[?#]|$)/i.test(url);
package/dist/index.d.ts CHANGED
@@ -49,6 +49,7 @@ export { HaruhimeWordmarkLink, type HaruhimeWordmarkLinkProps, } from "./compone
49
49
  export { JsonLd, type JsonLdProps } from "./components/meta/JsonLd.js";
50
50
  export { type BeatmapStatKey, BeatmapStats, type BeatmapStatsProps, } from "./components/osu/BeatmapStats.js";
51
51
  export { ModBadge, type ModBadgeProps } from "./components/osu/ModBadge.js";
52
+ export { PlayerCard, type PlayerCardProps, type PlayerTeam, } from "./components/osu/PlayerCard.js";
52
53
  export { StarRating, type StarRatingProps } from "./components/osu/StarRating.js";
53
54
  export { CommandPalette } from "./components/palette/CommandPalette.js";
54
55
  export { CommandPaletteButton, type CommandPaletteButtonProps, } from "./components/palette/CommandPaletteButton.js";
package/dist/index.js CHANGED
@@ -54,6 +54,7 @@ export { JsonLd } from "./components/meta/JsonLd.js";
54
54
  // osu!
55
55
  export { BeatmapStats, } from "./components/osu/BeatmapStats.js";
56
56
  export { ModBadge } from "./components/osu/ModBadge.js";
57
+ export { PlayerCard, } from "./components/osu/PlayerCard.js";
57
58
  export { StarRating } from "./components/osu/StarRating.js";
58
59
  // Palette
59
60
  export { CommandPalette } from "./components/palette/CommandPalette.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@haruhimemoe/ui",
3
- "version": "0.9.0",
4
- "description": "React components for the haruhime.moe osu! tools on Next.js: the osu!-web-style palette as a Tailwind theme, buttons, cards, form fields and confirms, filter controls, tables, osu! beatmap display pieces, a command palette and the site header and footer.",
3
+ "version": "0.10.0",
4
+ "description": "React components for the haruhime.moe osu! tools on Next.js: the osu!-web-style palette as a Tailwind theme, buttons, cards, form fields and confirms, filter controls, tables, osu! beatmap display pieces and player cards, a command palette and the site header and footer.",
5
5
  "keywords": [
6
6
  "osu",
7
7
  "osu!",