@haruhimemoe/ui 0.1.0 → 0.3.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,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-09-25
10
+
11
+ ### Added
12
+
13
+ - `DiscordIcon`: the Discord logo as an inline SVG in the current text color, sized and hidden from screen readers like `GitHubIcon`. The path is Simple Icons' `discord.svg` (CC0 1.0). Discord's brand guidelines ask for the logo in color, black or white, so set one of those as the text color.
14
+ - `SiteFooter` takes `discordHref`. When set, a Discord icon link named "Discord" sits before the GitHub icon in the last row, at the same size. The logo stays white and dims on hover instead of changing color, as Discord's guidelines ask. Without `discordHref` the footer is unchanged.
15
+
16
+ ## [0.2.0] - 2026-09-24
17
+
18
+ ### Added
19
+
20
+ - `Card` takes `headingLevel` (`2`, `3` or `4`, default `2`), like `FilterPanel`, for a card that sits under another heading.
21
+
22
+ ### Fixed
23
+
24
+ - `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.
25
+ - `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.
26
+
9
27
  ## [0.1.0] - 2026-09-23
10
28
 
11
29
  ### Added
@@ -19,5 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
19
37
  - `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
38
  - 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
39
 
22
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.1.0...HEAD
40
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.3.0...HEAD
41
+ [0.3.0]: https://github.com/haruhimemoe/ui/compare/v0.2.0...v0.3.0
42
+ [0.2.0]: https://github.com/haruhimemoe/ui/compare/v0.1.0...v0.2.0
23
43
  [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.3.0. Anything marked "since 0.3.0" is not in 0.2.0, and 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 `DiscordIcon`, `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.
@@ -313,9 +336,19 @@ schema.org structured data in a `<script type="application/ld+json">`. Every `<`
313
336
 
314
337
  ### Icons
315
338
 
339
+ #### `DiscordIcon`
340
+
341
+ Since 0.3.0. The Discord logo 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`.
342
+
343
+ The path is Simple Icons' `discord.svg` at tag 16.32.0 ([simple-icons/simple-icons](https://github.com/simple-icons/simple-icons), CC0 1.0). Discord is a trademark of Discord Inc. Its [brand guidelines](https://discord.com/branding) ask for the logo in color, black or white, and not recolored. The icon takes the current text color, so give the element around it one of those (`SiteFooter` uses white).
344
+
345
+ | Prop | Type | Default | What it does |
346
+ | --- | --- | --- | --- |
347
+ | `className` | `string` | `"size-5"` | Replaces the default size. |
348
+
316
349
  #### `GitHubIcon`
317
350
 
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.
351
+ 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
352
 
320
353
  | Prop | Type | Default | What it does |
321
354
  | --- | --- | --- | --- |
@@ -323,7 +356,7 @@ The GitHub mark as an inline SVG in the current text color. Always hidden from s
323
356
 
324
357
  #### `HaruhimeWordmark`
325
358
 
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.
359
+ 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
360
 
328
361
  | Prop | Type | Default | What it does |
329
362
  | --- | --- | --- | --- |
@@ -476,7 +509,7 @@ Links in the header and footer are data (type `SiteLinkItem`):
476
509
  type SiteLinkItem = { label: string; href?: string; note?: string };
477
510
  ```
478
511
 
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" }`).
512
+ 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
513
 
481
514
  #### `SiteHeader`
482
515
 
@@ -492,9 +525,15 @@ The dark top bar: brand on the left, the nav, and an actions slot on the right.
492
525
 
493
526
  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
527
 
495
- #### `NavLinks` (client)
528
+ 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.
529
+
530
+ 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.
531
+
532
+ 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.
533
+
534
+ #### `NavLinks`
496
535
 
497
- The `<ul>` of links `SiteHeader` uses, for building your own header. Put it inside a `<nav>`. Every native `<ul>` prop. Type: `SiteNavAlign`.
536
+ 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
537
 
499
538
  | Prop | Type | Default | What it does |
500
539
  | --- | --- | --- | --- |
@@ -503,17 +542,18 @@ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it insi
503
542
 
504
543
  #### `SiteFooter`
505
544
 
506
- Link columns, an extra slot, fine print, the haruhime.moe wordmark and a GitHub icon link. Every native `<footer>` prop. Type: `SiteFooterColumn` (`{ title: string; items: readonly SiteLinkItem[] }`).
545
+ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub icon link and an optional Discord icon link. Every native `<footer>` prop. Type: `SiteFooterColumn` (`{ title: string; items: readonly SiteLinkItem[] }`).
507
546
 
508
547
  | Prop | Type | Default | What it does |
509
548
  | --- | --- | --- | --- |
510
549
  | `columns` | `readonly SiteFooterColumn[]` | `[]` | Each column is a `<nav>` named by its title, which shows above the list. Up to four columns side by side from `sm` up. |
511
550
  | `extra` | `ReactNode` | none | Shown above the fine print, e.g. a "clear local data" button. |
512
551
  | `finePrint` | `ReactNode` | none | One line of small print, in a `<p>`. |
513
- | `parentLink` | `boolean` | `true` | Show the haruhime.moe wordmark linking the parent site. With it, the last row holds the wordmark and the GitHub icon, and the fine print sits above. Without it, the fine print shares the row with the icon. |
552
+ | `parentLink` | `boolean` | `true` | Show the haruhime.moe wordmark linking the parent site. With it, the last row holds the wordmark and the icons, and the fine print sits above. Without it, the fine print shares the row with the icons. |
514
553
  | `parentHref` | `string` | `"https://www.haruhime.moe"` | Where the wordmark links. |
515
554
  | `githubHref` | `string \| false` | `"https://github.com/haruhimemoe"` | Where the GitHub icon links. `false` leaves it out. |
516
555
  | `githubLabel` | `string` | `"haruhimemoe on GitHub"` | The GitHub link's accessible name. |
556
+ | `discordHref` | `string` | none | Where the Discord icon links, such as your server's invite (`https://discord.gg/...`). Without it there is no Discord icon. The icon sits before the GitHub icon at the same size. It stays white (`text-c1`) and dims on hover instead of changing color, since Discord's brand guidelines ask that the logo not be recolored. The link's accessible name is "Discord". Since 0.3.0. |
517
557
 
518
558
  #### `PageShell`
519
559
 
@@ -530,7 +570,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
530
570
 
531
571
  ## Accessibility
532
572
 
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.
573
+ - 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
574
  - 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
575
  - In forced colors mode, a pressed `Chip` takes the system highlight colors, so on and off still look different.
536
576
  - Form fields link their label, hint and error. An error sets `aria-invalid` and is announced.
@@ -539,7 +579,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
539
579
  - `CopyButton` announces "Copied." (or the failure) through an `<output>`, on every press.
540
580
  - `Pagination` moves focus to its "Page X of Y" text when the link you pressed goes away on the first or last page.
541
581
  - `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.
582
+ - `DiscordIcon` and `GitHubIcon` are hidden from screen readers by default: give the link around each one an `aria-label`, as `SiteFooter` does.
543
583
  - You supply the text, so you also supply labels: give icon-only buttons an `aria-label`, and keep `label` props meaningful.
544
584
 
545
585
  ## Compatibility
@@ -549,6 +589,7 @@ The page frame: a skip link, the header, `<main>` and the footer, with the foote
549
589
  | Next.js | 16 (app router). Components use `next/link` and `next/navigation`. |
550
590
  | React | 19 |
551
591
  | Tailwind CSS | 4.1 or later (4.x), through `@tailwindcss/postcss` |
592
+ | Node.js | 22.12 or later (`engines`) |
552
593
  | Module format | ESM only. Plain Node and Vitest can import it (for component tests in your app). |
553
594
  | Theme | Dark only |
554
595
 
@@ -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,21 @@
1
+ /**
2
+ * @file src/components/icons/DiscordIcon.tsx
3
+ * @desc The Discord logo as a static inline SVG (no icon library, no external request), in the
4
+ * current text color. Always decorative: label the link around it. The path is Simple
5
+ * Icons' discord.svg at tag 16.32.0 (https://github.com/simple-icons/simple-icons, CC0
6
+ * 1.0). Discord is a trademark of Discord Inc. Its brand guidelines ask for the logo in
7
+ * color, black or white and not recolored, so set one of those as the text color.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Fri Sep 25, 2026
10
+ * @modified Fri Sep 25, 2026
11
+ */
12
+ import type { ComponentProps } from "react";
13
+ /** Every native `<svg>` prop except children and viewBox. */
14
+ export type DiscordIconProps = Omit<ComponentProps<"svg">, "children" | "viewBox">;
15
+ /**
16
+ * @function DiscordIcon
17
+ * @param props {DiscordIconProps} native svg props; `className` replaces the default size
18
+ * ("size-5")
19
+ * @returns {JSX.Element} the Discord logo in the current text color, hidden from assistive tech
20
+ */
21
+ export declare function DiscordIcon({ className, ...props }: DiscordIconProps): import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * @function DiscordIcon
4
+ * @param props {DiscordIconProps} native svg props; `className` replaces the default size
5
+ * ("size-5")
6
+ * @returns {JSX.Element} the Discord logo in the current text color, hidden from assistive tech
7
+ */
8
+ export function DiscordIcon({ className = "size-5", ...props }) {
9
+ return (_jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", className: className, fill: "currentColor", ...props, children: _jsx("path", { d: "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" }) }));
10
+ }
@@ -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
+ }
@@ -2,10 +2,11 @@
2
2
  * @file src/components/shell/SiteFooter.tsx
3
3
  * @desc Site footer: labelled link columns from data (entries without an href show as text with
4
4
  * a small note, like "soon"), an optional extra slot, one line of fine print, the
5
- * haruhime.moe wordmark linking the parent site, and a GitHub icon link.
5
+ * haruhime.moe wordmark linking the parent site, a GitHub icon link, and an optional
6
+ * Discord icon link beside it (white, as Discord's brand guidelines ask).
6
7
  * @author David @dvhsh (https://dvh.sh)
7
8
  * @created Wed Sep 23, 2026
8
- * @modified Wed Sep 23, 2026
9
+ * @modified Fri Sep 25, 2026
9
10
  */
10
11
  import type { ComponentProps, ReactNode } from "react";
11
12
  import type { SiteLinkItem } from "./links.js";
@@ -30,11 +31,13 @@ export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
30
31
  githubHref?: string | false | undefined;
31
32
  /** The GitHub link's accessible name. Default "haruhimemoe on GitHub". */
32
33
  githubLabel?: string | undefined;
34
+ /** Where the Discord icon links, e.g. a server invite. No Discord icon without it. */
35
+ discordHref?: string | undefined;
33
36
  };
34
37
  /**
35
38
  * @function SiteFooter
36
- * @param props {SiteFooterProps} columns, extra slot, fine print, parent and GitHub links, and
37
- * native footer props
39
+ * @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
40
+ * links, and native footer props
38
41
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
39
42
  */
40
- export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
43
+ export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, className, ...props }: SiteFooterProps): import("react").JSX.Element;
@@ -1,21 +1,30 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../../utils/cx.js";
3
+ import { DiscordIcon } from "../icons/DiscordIcon.js";
3
4
  import { GitHubIcon } from "../icons/GitHubIcon.js";
4
5
  import { HaruhimeWordmarkLink } from "../icons/HaruhimeWordmarkLink.js";
5
6
  import { AutoLink } from "./AutoLink.js";
7
+ // The GitHub icon link in the bottom row.
8
+ const ICON_LINK = "shrink-0 text-c3 transition-colors hover:text-c1";
9
+ // Discord's brand guidelines ask for the logo in color, black or white, never recolored. c1 is
10
+ // white at every hue, so the Discord link stays white and dims on hover instead of changing hue.
11
+ const DISCORD_LINK = "shrink-0 text-c1 transition-opacity hover:opacity-80";
6
12
  // Static strings so Tailwind sees every class. Four or more columns share the four-column grid.
7
13
  const GRID_COLUMNS = ["", "", "sm:grid-cols-2", "sm:grid-cols-3", "sm:grid-cols-4"];
8
14
  /**
9
15
  * @function SiteFooter
10
- * @param props {SiteFooterProps} columns, extra slot, fine print, parent and GitHub links, and
11
- * native footer props
16
+ * @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
17
+ * links, and native footer props
12
18
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
13
19
  */
14
- export function SiteFooter({ columns = [], extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", className, ...props }) {
20
+ export function SiteFooter({ columns = [], extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", discordHref, className, ...props }) {
15
21
  const fine = finePrint ? _jsx("p", { className: "text-c4 text-xs", children: finePrint }) : null;
16
- const github = githubHref ? (_jsx("a", { href: githubHref, "aria-label": githubLabel, className: "shrink-0 text-c3 transition-colors hover:text-c1", children: _jsx(GitHubIcon, {}) })) : null;
22
+ const github = githubHref ? (_jsx("a", { href: githubHref, "aria-label": githubLabel, className: ICON_LINK, children: _jsx(GitHubIcon, {}) })) : null;
23
+ const discord = discordHref ? (_jsx("a", { href: discordHref, "aria-label": "Discord", className: DISCORD_LINK, children: _jsx(DiscordIcon, {}) })) : null;
24
+ // Both icons sit together at the row's end, Discord first. One icon stays a direct child.
25
+ const icons = discord && github ? (_jsxs("div", { className: "flex shrink-0 items-center gap-4", children: [discord, github] })) : ((discord ?? github));
17
26
  // With the wordmark, the fine print gets its own line and the row holds wordmark + icon.
18
27
  // Without it, the fine print shares the row with the icon.
19
28
  const rowStart = parentLink ? _jsx(HaruhimeWordmarkLink, { href: parentHref }) : fine;
20
- return (_jsx("footer", { className: cx("border-b4 border-t bg-b6 text-c3 text-sm", className), ...props, children: _jsxs("div", { className: "mx-auto flex max-w-5xl flex-col gap-8 px-4 py-10", children: [columns.length > 0 ? (_jsx("div", { className: cx("grid gap-8", GRID_COLUMNS[Math.min(columns.length, 4)]), children: columns.map((column) => (_jsxs("nav", { "aria-label": column.title, children: [_jsx("p", { className: "mb-3 font-bold text-c4 text-xs uppercase tracking-wide", children: column.title }), _jsx("ul", { className: "flex flex-col gap-2", children: column.items.map((item) => (_jsxs("li", { children: [item.href ? (_jsx(AutoLink, { href: item.href, className: "wrap-anywhere transition-colors hover:text-c1", children: item.label })) : (_jsx("span", { children: item.label })), item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-c4 text-xs uppercase tracking-wide", children: item.note })] })) : null] }, `${item.label} ${item.href ?? ""}`))) })] }, column.title))) })) : null, extra || fine || rowStart || github ? (_jsxs("div", { className: "flex flex-col gap-3 border-b4 border-t pt-6", children: [extra, parentLink ? fine : null, rowStart || github ? (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [rowStart, github] })) : null] })) : null] }) }));
29
+ return (_jsx("footer", { className: cx("border-b4 border-t bg-b6 text-c3 text-sm", className), ...props, children: _jsxs("div", { className: "mx-auto flex max-w-5xl flex-col gap-8 px-4 py-10", children: [columns.length > 0 ? (_jsx("div", { className: cx("grid gap-8", GRID_COLUMNS[Math.min(columns.length, 4)]), children: columns.map((column) => (_jsxs("nav", { "aria-label": column.title, children: [_jsx("p", { className: "mb-3 font-bold text-c4 text-xs uppercase tracking-wide", children: column.title }), _jsx("ul", { className: "flex flex-col gap-2", children: column.items.map((item) => (_jsxs("li", { children: [item.href ? (_jsx(AutoLink, { href: item.href, className: "wrap-anywhere transition-colors hover:text-c1", children: item.label })) : (_jsx("span", { children: item.label })), item.note ? (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-c4 text-xs uppercase tracking-wide", children: item.note })] })) : null] }, `${item.label} ${item.href ?? ""}`))) })] }, column.title))) })) : null, extra || fine || rowStart || icons ? (_jsxs("div", { className: "flex flex-col gap-3 border-b4 border-t pt-6", children: [extra, parentLink ? fine : null, rowStart || icons ? (_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [rowStart, icons] })) : null] })) : null] }) }));
21
30
  }
@@ -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/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * their own "use client" directive, so this barrel is safe to import from 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 Fri Sep 25, 2026
9
9
  */
10
10
  export { CopyButton, type CopyButtonProps } from "./components/actions/CopyButton.js";
11
11
  export { JsonLd, type JsonLdProps } from "./components/actions/JsonLd.js";
@@ -28,6 +28,7 @@ export { fieldClasses } from "./components/forms/fieldStyles.js";
28
28
  export { Select, type SelectProps } from "./components/forms/Select.js";
29
29
  export { Textarea, type TextareaProps } from "./components/forms/Textarea.js";
30
30
  export { TextInput, type TextInputProps } from "./components/forms/TextInput.js";
31
+ export { DiscordIcon, type DiscordIconProps } from "./components/icons/DiscordIcon.js";
31
32
  export { GitHubIcon, type GitHubIconProps } from "./components/icons/GitHubIcon.js";
32
33
  export { HaruhimeWordmark, type HaruhimeWordmarkProps, } from "./components/icons/HaruhimeWordmark.js";
33
34
  export { HaruhimeWordmarkLink, type HaruhimeWordmarkLinkProps, } from "./components/icons/HaruhimeWordmarkLink.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * their own "use client" directive, so this barrel is safe to import from 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 Fri Sep 25, 2026
9
9
  */
10
10
  // Actions
11
11
  export { CopyButton } from "./components/actions/CopyButton.js";
@@ -32,6 +32,7 @@ export { Select } from "./components/forms/Select.js";
32
32
  export { Textarea } from "./components/forms/Textarea.js";
33
33
  export { TextInput } from "./components/forms/TextInput.js";
34
34
  // Icons
35
+ export { DiscordIcon } from "./components/icons/DiscordIcon.js";
35
36
  export { GitHubIcon } from "./components/icons/GitHubIcon.js";
36
37
  export { HaruhimeWordmark, } from "./components/icons/HaruhimeWordmark.js";
37
38
  export { HaruhimeWordmarkLink, } from "./components/icons/HaruhimeWordmarkLink.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haruhimemoe/ui",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",