@haruhimemoe/ui 0.1.0 → 0.2.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,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-09-24
10
+
11
+ ### Added
12
+
13
+ - `Card` takes `headingLevel` (`2`, `3` or `4`, default `2`), like `FilterPanel`, for a card that sits under another heading.
14
+
15
+ ### Fixed
16
+
17
+ - `SiteHeader` and `NavLinks` no longer send tailwind-merge to the browser on every page. `NavLinks` is now a Server Component: it merges its classes on the server and hands finished class strings to a small client list that only reads the path for `aria-current`. In a Next.js 16 build, the nav's client chunk drops from about 12 KB to 4 KB gzipped. When no link can be the current page (all external or text-only), the list renders on the server alone and nothing in the nav hydrates.
18
+ - `Prose` no longer puts the `h2` or `h3` top margin above a heading that opens the block: its first child gets `mt-0`. The README shows the one-class pattern for content wrapped in `<section>`s.
19
+
9
20
  ## [0.1.0] - 2026-09-23
10
21
 
11
22
  ### Added
@@ -19,5 +30,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
19
30
  - `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`).
20
31
  - 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).
21
32
 
22
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.1.0...HEAD
33
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.2.0...HEAD
34
+ [0.2.0]: https://github.com/haruhimemoe/ui/compare/v0.1.0...v0.2.0
23
35
  [0.1.0]: https://github.com/haruhimemoe/ui/releases/tag/v0.1.0
package/README.md CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  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, cards, form fields, filter controls (toggle chips, a two-thumb range slider, a filter panel) and the site header, footer 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.
4
4
 
5
+ See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
6
+
7
+ This README describes version 0.2.0. Anything marked "since 0.2.0" is not in 0.1.0. [CHANGELOG.md](./CHANGELOG.md) lists what changed in each version.
8
+
5
9
  ## Requirements
6
10
 
7
11
  - Next.js 16 (app router)
8
12
  - React 19
9
- - Tailwind CSS 4.1 or later
13
+ - Tailwind CSS 4.1 or later, below 5
10
14
 
11
- These are peer dependencies. The package is ESM only.
15
+ These are peer dependencies. The package is ESM only, and its `engines` field asks for Node.js 22.12 or later.
12
16
 
13
17
  ## Install
14
18
 
@@ -147,16 +151,17 @@ export default function Home() {
147
151
 
148
152
  Import every component from `@haruhimemoe/ui`, in Server and Client Components alike.
149
153
 
150
- - **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider`, `FilterPanel` and `NavLinks` (which `SiteHeader` renders for you). Each file starts with `"use client"`.
154
+ - **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider` and `FilterPanel`. Each file starts with `"use client"`.
155
+ - **`SiteHeader` and `NavLinks`** are Server Components with a small client part (since 0.2.0; in 0.1.0 `NavLinks` is a client component). When a nav link can be the current page (a path such as `/packs`), a client list reads the path to set `aria-current`. With only external or text-only links, the nav renders on the server alone and nothing in it hydrates. Relative hrefs (`#main`) skip the client list too, but they render `next/link`, which hydrates.
151
156
  - **Everything else is server-safe:** no state, no effects, no browser APIs.
152
157
 
153
158
  A Server Component can't pass a function to a Client Component. So callback props (`onChange`, `onPressedChange`, `onClear`) have to come from your own `"use client"` file, like the filters example below. Props that are plain data (`CopyButton`'s `text`, `Chip`'s `pressed`) work from a Server Component. `Pagination` takes a function (`hrefFor`), but it is a Server Component itself, so that is fine anywhere.
154
159
 
155
160
  ## Props, classes and refs
156
161
 
157
- - Every component takes its element's native props and passes them through (`id`, `aria-*`, `data-*`, event handlers). The tables below list only the extra props.
158
- - `ref` is a normal prop (React 19). Like the native props, it goes on the component's outer element (for `PageShell`, the wrapper `<div>`, not `<main>`).
159
- - `className` is added after the built-in classes and wins on conflict: a class that sets the same property as a built-in one replaces it (merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge)). `<Select className="w-auto">` drops the built-in `w-full`.
162
+ - Every component takes its element's native props and passes them through (`id`, `aria-*`, `data-*`, event handlers). Each section below names that element. The tables list only the extra props.
163
+ - `ref` is a normal prop (React 19). It goes where the native props go: the outer element for most components, the control (`<input>`, `<select>`, `<textarea>`) for the form fields, and the `<button>` for `CopyButton`. On `PageShell` that is the wrapper `<div>`, not `<main>`.
164
+ - `className` is added after the built-in classes and wins on conflict: a class that sets the same property as a built-in one replaces it (merged with [tailwind-merge](https://github.com/dcastil/tailwind-merge)). `<Select className="w-auto">` drops the built-in `w-full`. On `GitHubIcon` and `HaruhimeWordmark`, `className` replaces the default size instead.
160
165
 
161
166
  ## Components
162
167
 
@@ -176,8 +181,8 @@ A pill button. Every native `<button>` prop.
176
181
 
177
182
  A link that looks like `Button`. Every `next/link` prop (`href`, `prefetch`, `replace`, `scroll`, `target`, `rel`...), plus `variant` and `size` as on `Button`.
178
183
 
179
- - An `href` with a scheme (`https:`, `mailto:`) or starting with `//` renders a plain `<a>`, and `next/link`'s own props are dropped.
180
- - With `target="_blank"` and no `rel`, it adds `rel="noreferrer"`. A `rel` you pass always wins.
184
+ - A string `href` with a scheme (`https:`, `mailto:`) or starting with `//` renders a plain `<a>`, and `next/link`'s own props are dropped.
185
+ - That plain `<a>` with `target="_blank"` and no `rel` gets `rel="noreferrer"`. A `rel` you pass always wins. Internal links get only the `rel` you pass.
181
186
 
182
187
  #### `buttonClasses`
183
188
 
@@ -193,7 +198,8 @@ The osu!-web panel: rounded, `b4` background, `p-5`. Every native `<section>` pr
193
198
 
194
199
  | Prop | Type | Default | What it does |
195
200
  | --- | --- | --- | --- |
196
- | `title` | `ReactNode` | none | Rendered as an `<h2>` at the top. It also names the section (`aria-labelledby`), which makes the card a region landmark. |
201
+ | `title` | `ReactNode` | none | Rendered as a heading at the top (`<h2>` by default). It also names the section (`aria-labelledby`), which makes the card a region landmark. |
202
+ | `headingLevel` | `2 \| 3 \| 4` | `2` | The title's heading level. Use `3` or `4` for a card that sits under another heading, such as a card inside a titled card. Since 0.2.0. |
197
203
 
198
204
  #### `PageHeader`
199
205
 
@@ -228,6 +234,23 @@ An error notice (`role="alert"`) is announced either way.
228
234
 
229
235
  Long-form typography for MDX, docs and legal pages. A `max-w-3xl` `<div>` that styles the `h2`, `h3`, `p`, `a`, `strong`, `ul`, `ol`, `li`, `code`, `pre`, `hr` and `table` elements inside it. Every native `<div>` prop.
230
236
 
237
+ The first element inside gets no top margin (`[&>:first-child]:mt-0`, since 0.2.0), so a heading that opens the block sits flush with what's above it instead of taking the `h2` or `h3` gap. The rule reaches direct children only. If you wrap the content in `<section>`s, the heading at the top of the first section keeps its margin. Reach one level deeper for that:
238
+
239
+ ```tsx
240
+ <Prose className="[&>:first-child>:first-child]:mt-0">
241
+ <section>
242
+ <h2>What we store</h2>
243
+ <p>Your osu! id and your packs.</p>
244
+ </section>
245
+ <section>
246
+ <h2>How long we keep it</h2>
247
+ <p>Until you delete your account.</p>
248
+ </section>
249
+ </Prose>
250
+ ```
251
+
252
+ Later sections keep their heading margin, which spaces them apart.
253
+
231
254
  ### Forms
232
255
 
233
256
  The fields render a label, the control, an optional hint and an optional error, wired together for screen readers. They are Server Components: you pass the `id`, so they need no generated ids.
@@ -315,7 +338,7 @@ schema.org structured data in a `<script type="application/ld+json">`. Every `<`
315
338
 
316
339
  #### `GitHubIcon`
317
340
 
318
- The GitHub mark as an inline SVG in the current text color. Always hidden from screen readers, so put a label on the link around it. Every native `<svg>` prop.
341
+ The GitHub mark as an inline SVG in the current text color. Hidden from screen readers by default (`aria-hidden="true"`), so put a label on the link around it. Every native `<svg>` prop except `children` and `viewBox`.
319
342
 
320
343
  | Prop | Type | Default | What it does |
321
344
  | --- | --- | --- | --- |
@@ -323,7 +346,7 @@ The GitHub mark as an inline SVG in the current text color. Always hidden from s
323
346
 
324
347
  #### `HaruhimeWordmark`
325
348
 
326
- The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and pink whatever `--hue` is. Every native `<svg>` prop.
349
+ The haruhime.moe wordmark as an inline SVG. It keeps the brand's own white and pink whatever `--hue` is. Every native `<svg>` prop except `children` and `viewBox`.
327
350
 
328
351
  | Prop | Type | Default | What it does |
329
352
  | --- | --- | --- | --- |
@@ -476,7 +499,7 @@ Links in the header and footer are data (type `SiteLinkItem`):
476
499
  type SiteLinkItem = { label: string; href?: string; note?: string };
477
500
  ```
478
501
 
479
- Paths use `next/link`; anything with a scheme (`https:`, `mailto:`) or starting with `//` is a plain `<a>`. An item without `href` shows as muted text with its `note` beside it in small uppercase letters (`{ label: "Pools", note: "soon" }`).
502
+ Paths use `next/link`; anything with a scheme (`https:`, `mailto:`) or starting with `//` is a plain `<a>`. An item without `href` shows as plain text. `note` adds a word beside it in small uppercase letters (`{ label: "Pools", note: "soon" }`). The header dims text-only items and shows the note only on them. The footer shows a note beside a link too.
480
503
 
481
504
  #### `SiteHeader`
482
505
 
@@ -492,9 +515,15 @@ The dark top bar: brand on the left, the nav, and an actions slot on the right.
492
515
 
493
516
  The link for the current page gets `aria-current="page"` and lights up. A section link gets `aria-current="true"` on pages under it (`/packs` while on `/packs/123`). `/` only matches itself.
494
517
 
495
- #### `NavLinks` (client)
518
+ Only a path inside the app can be the current page. External URLs, relative hrefs (`#main`, `?page=2`) and text-only entries never are. Since 0.2.0, when no link can be, the nav skips the client list. With only external and text-only entries, it renders on the server alone and nothing in it hydrates. A relative href still renders `next/link`, which hydrates. When a link can be the current page, a small client list marks it. In 0.1.0 the whole nav is a client component, so it always hydrates.
519
+
520
+ Rendered from a Server Component, `SiteHeader` and `NavLinks` merge the nav's classes on the server, so tailwind-merge stays out of the browser (since 0.2.0; in 0.1.0 the nav always brings tailwind-merge to the browser). Rendered inside a Client Component, they merge them in the browser and bring tailwind-merge with them.
521
+
522
+ Next bundles every client component a route imports, rendered or not. So a page with `SiteHeader` still downloads the client list's small chunk (mostly `next/link`), even when the nav rendered on the server alone.
523
+
524
+ #### `NavLinks`
496
525
 
497
- The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop. Type: `SiteNavAlign`.
526
+ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop. Type: `SiteNavAlign`. It works in Server and Client Components. Since 0.2.0 it is a Server Component with a small client part (in 0.1.0 it is a client component). From a Server Component it behaves like `SiteHeader`'s nav. Inside a Client Component (a header with a menu toggle, say), it renders in the browser with the rest of that component: it merges its classes there, so tailwind-merge ships in that page's bundle.
498
527
 
499
528
  | Prop | Type | Default | What it does |
500
529
  | --- | --- | --- | --- |
@@ -530,7 +559,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
530
559
 
531
560
  ## Accessibility
532
561
 
533
- - Every component is checked with axe against the WCAG 2.2 A and AA rules in the test suite (all but color contrast, which needs a real browser). Interactive ones also have keyboard tests.
562
+ - Every component is checked in the test suite with axe-core's WCAG 2.0, 2.1 and 2.2 A and AA rules (all but color contrast, which needs a real browser). Interactive ones also have keyboard tests. The tests also calculate the contrast figures under Setup (`c1` on `h2`, `h1` on `b4`, and the `--h1-l` and `--h2-l` values).
534
563
  - Focus is always visible: the theme draws an `h1` outline on `:focus-visible`. Fields show focus with an `h1` border instead (plus an `h1` ring when invalid), and `RangeSlider` thumbs with a solid `h1` ring. Those keep a transparent outline, so Windows high contrast mode (forced colors) still shows focus.
535
564
  - In forced colors mode, a pressed `Chip` takes the system highlight colors, so on and off still look different.
536
565
  - Form fields link their label, hint and error. An error sets `aria-invalid` and is announced.
@@ -539,7 +568,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
539
568
  - `CopyButton` announces "Copied." (or the failure) through an `<output>`, on every press.
540
569
  - `Pagination` moves focus to its "Page X of Y" text when the link you pressed goes away on the first or last page.
541
570
  - `SiteHeader` marks the current page with `aria-current`. `PageShell` starts with a skip link to `<main>`.
542
- - `GitHubIcon` is always hidden from screen readers: give the link around it an `aria-label`, as `SiteFooter` does.
571
+ - `GitHubIcon` is hidden from screen readers by default: give the link around it an `aria-label`, as `SiteFooter` does.
543
572
  - You supply the text, so you also supply labels: give icon-only buttons an `aria-label`, and keep `label` props meaningful.
544
573
 
545
574
  ## Compatibility
@@ -549,6 +578,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
549
578
  | Next.js | 16 (app router). Components use `next/link` and `next/navigation`. |
550
579
  | React | 19 |
551
580
  | Tailwind CSS | 4.1 or later (4.x), through `@tailwindcss/postcss` |
581
+ | Node.js | 22.12 or later (`engines`) |
552
582
  | Module format | ESM only. Plain Node and Vitest can import it (for component tests in your app). |
553
583
  | Theme | Dark only |
554
584
 
@@ -1,19 +1,22 @@
1
1
  /**
2
2
  * @file src/components/basics/Card.tsx
3
- * @desc osu!-web panel: rounded b4 surface, optional h2 title that labels the region. Server-safe
4
- * (useId works in Server Components).
3
+ * @desc osu!-web panel: rounded b4 surface, optional title (an h2 by default) that labels the
4
+ * region. Server-safe (useId works in Server Components).
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
7
+ * @modified Thu Sep 24, 2026
8
8
  */
9
9
  import { type ComponentProps, type ReactNode } from "react";
10
- /** Every native `<section>` prop (including `ref`), with `title` rendered as the card's h2. */
10
+ /** Every native `<section>` prop (including `ref`), with `title` rendered as the card's heading. */
11
11
  export type CardProps = Omit<ComponentProps<"section">, "title"> & {
12
12
  title?: ReactNode | undefined;
13
+ /** Heading level for the title (default 2). Use 3 or 4 for a card under another heading. */
14
+ headingLevel?: 2 | 3 | 4 | undefined;
13
15
  };
14
16
  /**
15
17
  * @function Card
16
- * @param props {CardProps} an optional title that labels the region, plus native section props
18
+ * @param props {CardProps} an optional title that labels the region, its heading level (default
19
+ * 2), plus native section props
17
20
  * @returns {JSX.Element} a rounded panel, labelled by its title when one is given
18
21
  */
19
- export declare function Card({ title, className, children, ...props }: CardProps): import("react").JSX.Element;
22
+ export declare function Card({ title, headingLevel, className, children, ...props }: CardProps): import("react").JSX.Element;
@@ -1,20 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * @file src/components/basics/Card.tsx
4
- * @desc osu!-web panel: rounded b4 surface, optional h2 title that labels the region. Server-safe
5
- * (useId works in Server Components).
4
+ * @desc osu!-web panel: rounded b4 surface, optional title (an h2 by default) that labels the
5
+ * region. Server-safe (useId works in Server Components).
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
8
+ * @modified Thu Sep 24, 2026
9
9
  */
10
10
  import { useId } from "react";
11
11
  import { cx } from "../../utils/cx.js";
12
12
  /**
13
13
  * @function Card
14
- * @param props {CardProps} an optional title that labels the region, plus native section props
14
+ * @param props {CardProps} an optional title that labels the region, its heading level (default
15
+ * 2), plus native section props
15
16
  * @returns {JSX.Element} a rounded panel, labelled by its title when one is given
16
17
  */
17
- export function Card({ title, className, children, ...props }) {
18
+ export function Card({ title, headingLevel = 2, className, children, ...props }) {
18
19
  const headingId = useId();
19
- return (_jsxs("section", { "aria-labelledby": title ? headingId : undefined, className: cx("rounded-[10px] bg-b4 p-5 text-c2", className), ...props, children: [title ? (_jsx("h2", { id: headingId, className: "mb-2 font-bold text-c1 text-lg", children: title })) : null, children] }));
20
+ const Heading = `h${headingLevel}`;
21
+ return (_jsxs("section", { "aria-labelledby": title ? headingId : undefined, className: cx("rounded-[10px] bg-b4 p-5 text-c2", className), ...props, children: [title ? (_jsx(Heading, { id: headingId, className: "mb-2 font-bold text-c1 text-lg", children: title })) : null, children] }));
20
22
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * @file src/components/basics/Prose.tsx
3
3
  * @desc Long-form typography for MDX, docs and legal text, styled with the osu!-web tokens.
4
- * Styles the plain elements inside it (headings, links, lists, code, tables).
4
+ * Styles the plain elements inside it (headings, links, lists, code, tables). The element
5
+ * that opens the block gets no top margin, so a leading heading sits flush.
5
6
  * @author David @dvhsh (https://dvh.sh)
6
7
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
8
+ * @modified Thu Sep 24, 2026
8
9
  */
9
10
  import type { ComponentProps } from "react";
10
11
  /** Every native `<div>` prop (including `ref`). */
@@ -13,6 +14,7 @@ export type ProseProps = ComponentProps<"div">;
13
14
  * @function Prose
14
15
  * @param props {ProseProps} native div props; children are the rendered Markdown or MDX
15
16
  * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
16
- * tables inside it
17
+ * tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
18
+ * which outranks the h2 and h3 margins by specificity.
17
19
  */
18
20
  export declare function Prose({ className, ...props }: ProseProps): import("react").JSX.Element;
@@ -4,8 +4,9 @@ import { cx } from "../../utils/cx.js";
4
4
  * @function Prose
5
5
  * @param props {ProseProps} native div props; children are the rendered Markdown or MDX
6
6
  * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
7
- * tables inside it
7
+ * tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
8
+ * which outranks the h2 and h3 margins by specificity.
8
9
  */
9
10
  export function Prose({ className, ...props }) {
10
- return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre]:mt-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-b6 [&_pre]:p-3 [&_pre]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
11
+ return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&>:first-child]:mt-0 [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre]:mt-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-b6 [&_pre]:p-3 [&_pre]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
11
12
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file src/components/shell/NavItem.tsx
3
+ * @desc One entry of the header's nav list (internal): a link, or dimmed text with its note when
4
+ * the entry has no href. NavLinks renders it on the server, and NavListClient in the
5
+ * browser, so both lists print the same markup.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Thu Sep 24, 2026
8
+ * @modified Thu Sep 24, 2026
9
+ */
10
+ import type { SiteLinkItem } from "./links.js";
11
+ /** One nav entry, its aria-current and the finished classes for its link. */
12
+ export type NavItemProps = {
13
+ item: SiteLinkItem;
14
+ /** The link's aria-current, from `ariaCurrentFor`. */
15
+ current?: "page" | "true" | undefined;
16
+ /** Classes for the link. Ignored for an entry without `href`. */
17
+ linkClassName: string;
18
+ };
19
+ /**
20
+ * @function navItemKey
21
+ * @param item {SiteLinkItem} a nav entry
22
+ * @returns {string} its React key: the href, or the label for a text-only entry
23
+ */
24
+ export declare const navItemKey: (item: SiteLinkItem) => string;
25
+ /**
26
+ * @function NavItem
27
+ * @param props {NavItemProps} the entry, its aria-current and its link classes
28
+ * @returns {JSX.Element} an `<li>` with the link, or with dimmed text and the note beside it
29
+ */
30
+ export declare function NavItem({ item, current, linkClassName }: NavItemProps): import("react").JSX.Element;
@@ -0,0 +1,28 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @file src/components/shell/NavItem.tsx
4
+ * @desc One entry of the header's nav list (internal): a link, or dimmed text with its note when
5
+ * the entry has no href. NavLinks renders it on the server, and NavListClient in the
6
+ * browser, so both lists print the same markup.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Thu Sep 24, 2026
9
+ * @modified Thu Sep 24, 2026
10
+ */
11
+ import { AutoLink } from "./AutoLink.js";
12
+ /**
13
+ * @function navItemKey
14
+ * @param item {SiteLinkItem} a nav entry
15
+ * @returns {string} its React key: the href, or the label for a text-only entry
16
+ */
17
+ export const navItemKey = (item) => item.href || item.label;
18
+ /**
19
+ * @function NavItem
20
+ * @param props {NavItemProps} the entry, its aria-current and its link classes
21
+ * @returns {JSX.Element} an `<li>` with the link, or with dimmed text and the note beside it
22
+ */
23
+ export function NavItem({ item, current, linkClassName }) {
24
+ if (!item.href) {
25
+ return (_jsx("li", { children: _jsxs("span", { "aria-disabled": "true", className: "text-c4", children: [item.label, item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-xs uppercase tracking-wide", children: item.note })] })) : null] }) }));
26
+ }
27
+ return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: linkClassName, children: item.label }) }));
28
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * @file src/components/shell/NavLinks.tsx
3
- * @desc The header's nav list. A client component only so it can read the current path and set
4
- * aria-current on the matching link; SiteHeader around it stays a server component.
3
+ * @desc The header's nav list. Server-safe: it merges the classes here, then renders the list
4
+ * itself when no link can be the current page (external, relative or text-only), or hands
5
+ * it to the small NavListClient, which reads the path to set aria-current.
5
6
  * @author David @dvhsh (https://dvh.sh)
6
7
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
8
+ * @modified Thu Sep 24, 2026
8
9
  */
9
10
  import type { ComponentProps } from "react";
10
11
  import { type SiteLinkItem } from "./links.js";
@@ -19,6 +20,9 @@ export type NavLinksProps = Omit<ComponentProps<"ul">, "children"> & {
19
20
  * @function NavLinks
20
21
  * @param props {NavLinksProps} the links (items without `href` show as dimmed text with their
21
22
  * `note`), the alignment (default "start") and native list props
22
- * @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
23
+ * @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current.
24
+ * When no link can be current, it skips the client list and nothing reads the path.
25
+ * From a Server Component with only external and text-only links, nothing hydrates; a
26
+ * relative href still renders next/link, which does.
23
27
  */
24
28
  export declare function NavLinks({ links, align, className, ...props }: NavLinksProps): import("react").JSX.Element;
@@ -1,17 +1,8 @@
1
- /**
2
- * @file src/components/shell/NavLinks.tsx
3
- * @desc The header's nav list. A client component only so it can read the current path and set
4
- * aria-current on the matching link; SiteHeader around it stays a server component.
5
- * @author David @dvhsh (https://dvh.sh)
6
- * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
8
- */
9
- "use client";
10
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
- import { usePathname } from "next/navigation.js";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
12
2
  import { cx } from "../../utils/cx.js";
13
- import { AutoLink } from "./AutoLink.js";
14
- import { ariaCurrentFor } from "./links.js";
3
+ import { canBeCurrent } from "./links.js";
4
+ import { NavItem, navItemKey } from "./NavItem.js";
5
+ import { NavListClient } from "./NavListClient.js";
15
6
  const LISTS = {
16
7
  start: "flex flex-wrap gap-x-5 gap-y-1 font-bold text-sm",
17
8
  center: "flex flex-wrap items-center justify-center gap-x-6 gap-y-1 font-bold",
@@ -26,15 +17,15 @@ const CURRENT = "text-c1 transition-colors";
26
17
  * @function NavLinks
27
18
  * @param props {NavLinksProps} the links (items without `href` show as dimmed text with their
28
19
  * `note`), the alignment (default "start") and native list props
29
- * @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
20
+ * @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current.
21
+ * When no link can be current, it skips the client list and nothing reads the path.
22
+ * From a Server Component with only external and text-only links, nothing hydrates; a
23
+ * relative href still renders next/link, which does.
30
24
  */
31
25
  export function NavLinks({ links, align = "start", className, ...props }) {
32
- const pathname = usePathname();
33
- return (_jsx("ul", { className: cx(LISTS[align], className), ...props, children: links.map((item) => {
34
- if (!item.href) {
35
- return (_jsx("li", { children: _jsxs("span", { "aria-disabled": "true", className: "text-c4", children: [item.label, item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-xs uppercase tracking-wide", children: item.note })] })) : null] }) }, item.label));
36
- }
37
- const current = ariaCurrentFor(pathname, item.href);
38
- return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: current ? CURRENT : LINKS[align], children: item.label }) }, item.href));
39
- }) }));
26
+ const listClassName = cx(LISTS[align], className);
27
+ if (!links.some((item) => item.href && canBeCurrent(item.href))) {
28
+ return (_jsx("ul", { className: listClassName, ...props, children: links.map((item) => (_jsx(NavItem, { item: item, linkClassName: LINKS[align] }, navItemKey(item)))) }));
29
+ }
30
+ return (_jsx(NavListClient, { links: links, linkClassName: LINKS[align], currentClassName: CURRENT, className: listClassName, ...props }));
40
31
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file src/components/shell/NavListClient.tsx
3
+ * @desc The nav list that marks the current page (internal). The only client part of the header:
4
+ * it reads the pathname to set aria-current. Its classes arrive finished from NavLinks on
5
+ * the server, so it imports no class merging and ships no tailwind-merge to the browser.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Thu Sep 24, 2026
8
+ * @modified Thu Sep 24, 2026
9
+ */
10
+ import type { ComponentProps } from "react";
11
+ import { type SiteLinkItem } from "./links.js";
12
+ /** Every native `<ul>` prop (including `ref`), plus the links and their finished classes. */
13
+ export type NavListClientProps = Omit<ComponentProps<"ul">, "children"> & {
14
+ links: readonly SiteLinkItem[];
15
+ /** Classes for a link that is not the current page. */
16
+ linkClassName: string;
17
+ /** Classes for the current page's link. */
18
+ currentClassName: string;
19
+ };
20
+ /**
21
+ * @function NavListClient
22
+ * @param props {NavListClientProps} the links, the classes for current and other links, and
23
+ * native list props (`className` already merged)
24
+ * @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
25
+ */
26
+ export declare function NavListClient({ links, linkClassName, currentClassName, ...props }: NavListClientProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file src/components/shell/NavListClient.tsx
3
+ * @desc The nav list that marks the current page (internal). The only client part of the header:
4
+ * it reads the pathname to set aria-current. Its classes arrive finished from NavLinks on
5
+ * the server, so it imports no class merging and ships no tailwind-merge to the browser.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Thu Sep 24, 2026
8
+ * @modified Thu Sep 24, 2026
9
+ */
10
+ "use client";
11
+ import { jsx as _jsx } from "react/jsx-runtime";
12
+ import { usePathname } from "next/navigation.js";
13
+ import { ariaCurrentFor } from "./links.js";
14
+ import { NavItem, navItemKey } from "./NavItem.js";
15
+ /**
16
+ * @function NavListClient
17
+ * @param props {NavListClientProps} the links, the classes for current and other links, and
18
+ * native list props (`className` already merged)
19
+ * @returns {JSX.Element} a `<ul>` of links, the one for the current path marked aria-current
20
+ */
21
+ export function NavListClient({ links, linkClassName, currentClassName, ...props }) {
22
+ const pathname = usePathname();
23
+ return (_jsx("ul", { ...props, children: links.map((item) => {
24
+ const current = item.href ? ariaCurrentFor(pathname, item.href) : undefined;
25
+ return (_jsx(NavItem, { item: item, current: current, linkClassName: current ? currentClassName : linkClassName }, navItemKey(item)));
26
+ }) }));
27
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * @file src/components/shell/SiteHeader.tsx
3
3
  * @desc Site header, the osu!-web dark bar: a brand slot on the left, nav links from data, and an
4
- * actions slot on the right (an account menu, say). A server component; only the nav list
5
- * inside is a client component, to mark the current page.
4
+ * actions slot on the right (an account menu, say). A server component. The nav list is a
5
+ * small client component only when a link can be the current page; with only external or
6
+ * text-only links the whole header renders on the server.
6
7
  * @author David @dvhsh (https://dvh.sh)
7
8
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
9
+ * @modified Thu Sep 24, 2026
9
10
  */
10
11
  import type { ComponentProps, ReactNode } from "react";
11
12
  import type { SiteLinkItem } from "./links.js";
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * @file src/components/shell/links.ts
3
- * @desc Link data shared by SiteHeader and SiteFooter, plus the two rules they apply to it: which
4
- * hrefs leave the app (plain `<a>`) and which link marks the current page.
3
+ * @desc Link data shared by SiteHeader and SiteFooter, plus the rules they apply to it: which
4
+ * hrefs leave the app (plain `<a>`), which link marks the current page, and which hrefs can
5
+ * ever be the current page.
5
6
  * @author David @dvhsh (https://dvh.sh)
6
7
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
8
+ * @modified Thu Sep 24, 2026
8
9
  */
9
10
  import { isExternalHref } from "../../utils/href.js";
10
11
  export { isExternalHref };
@@ -14,6 +15,14 @@ export type SiteLinkItem = {
14
15
  href?: string | undefined;
15
16
  note?: string | undefined;
16
17
  };
18
+ /**
19
+ * @function canBeCurrent
20
+ * @param href {string} a nav link target
21
+ * @returns {boolean} true when some pathname makes `ariaCurrentFor` mark the link, which is only
22
+ * for a path in the app ("/packs"). False for external, relative, fragment-only and
23
+ * query-only hrefs.
24
+ */
25
+ export declare const canBeCurrent: (href: string) => boolean;
17
26
  /**
18
27
  * @function ariaCurrentFor
19
28
  * @param pathname {string | null} the current pathname (`usePathname()`), null outside the router
@@ -1,14 +1,31 @@
1
1
  /**
2
2
  * @file src/components/shell/links.ts
3
- * @desc Link data shared by SiteHeader and SiteFooter, plus the two rules they apply to it: which
4
- * hrefs leave the app (plain `<a>`) and which link marks the current page.
3
+ * @desc Link data shared by SiteHeader and SiteFooter, plus the rules they apply to it: which
4
+ * hrefs leave the app (plain `<a>`), which link marks the current page, and which hrefs can
5
+ * ever be the current page.
5
6
  * @author David @dvhsh (https://dvh.sh)
6
7
  * @created Wed Sep 23, 2026
7
- * @modified Wed Sep 23, 2026
8
+ * @modified Thu Sep 24, 2026
8
9
  */
9
10
  import { isExternalHref } from "../../utils/href.js";
10
11
  export { isExternalHref };
11
12
  const trimSlash = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
13
+ // The app path an href points at, without query, fragment or trailing slash. Undefined for an
14
+ // href that leaves the app or is relative ("packs", "#main", "?page=2").
15
+ const appPath = (href) => {
16
+ if (isExternalHref(href))
17
+ return undefined;
18
+ const target = trimSlash(href.replace(/[?#].*$/s, ""));
19
+ return target.startsWith("/") ? target : undefined;
20
+ };
21
+ /**
22
+ * @function canBeCurrent
23
+ * @param href {string} a nav link target
24
+ * @returns {boolean} true when some pathname makes `ariaCurrentFor` mark the link, which is only
25
+ * for a path in the app ("/packs"). False for external, relative, fragment-only and
26
+ * query-only hrefs.
27
+ */
28
+ export const canBeCurrent = (href) => appPath(href) !== undefined;
12
29
  /**
13
30
  * @function ariaCurrentFor
14
31
  * @param pathname {string | null} the current pathname (`usePathname()`), null outside the router
@@ -18,10 +35,8 @@ const trimSlash = (path) => path.length > 1 && path.endsWith("/") ? path.slice(0
18
35
  * External and relative hrefs never match, and "/" only matches itself.
19
36
  */
20
37
  export function ariaCurrentFor(pathname, href) {
21
- if (!pathname || isExternalHref(href))
22
- return undefined;
23
- const target = trimSlash(href.replace(/[?#].*$/s, ""));
24
- if (!target.startsWith("/"))
38
+ const target = appPath(href);
39
+ if (!pathname || target === undefined)
25
40
  return undefined;
26
41
  const here = trimSlash(pathname);
27
42
  if (here === target)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haruhimemoe/ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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, filter controls and the site header and footer.",
5
5
  "keywords": [
6
6
  "osu",