@haruhimemoe/ui 0.5.1 → 0.7.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +35 -25
  3. package/dist/components/filters/RangeSlider.d.ts +1 -1
  4. package/dist/components/filters/RangeSlider.js +6 -6
  5. package/dist/components/filters/chipStyles.d.ts +2 -2
  6. package/dist/components/filters/chipStyles.js +3 -2
  7. package/dist/components/forms/CharCounter.d.ts +4 -2
  8. package/dist/components/forms/CharCounter.js +5 -3
  9. package/dist/components/forms/FieldFrame.d.ts +1 -1
  10. package/dist/components/forms/FieldFrame.js +1 -1
  11. package/dist/components/forms/RadioGroup.d.ts +1 -1
  12. package/dist/components/forms/RadioGroup.js +2 -2
  13. package/dist/components/forms/ReportDisclosure.d.ts +1 -1
  14. package/dist/components/forms/ReportDisclosure.js +10 -6
  15. package/dist/components/forms/fieldStyles.d.ts +5 -6
  16. package/dist/components/forms/fieldStyles.js +6 -7
  17. package/dist/components/osu/BeatmapStats.d.ts +1 -1
  18. package/dist/components/osu/BeatmapStats.js +2 -1
  19. package/dist/components/osu/starColors.d.ts +14 -4
  20. package/dist/components/osu/starColors.js +39 -2
  21. package/dist/components/shell/NavItem.d.ts +1 -1
  22. package/dist/components/shell/NavItem.js +2 -2
  23. package/dist/components/shell/SiteFooter.d.ts +18 -6
  24. package/dist/components/shell/SiteFooter.js +11 -3
  25. package/dist/components/shell/haruhimeTools.d.ts +46 -0
  26. package/dist/components/shell/haruhimeTools.js +30 -0
  27. package/dist/components/tables/Table.d.ts +5 -3
  28. package/dist/components/tables/Table.js +15 -2
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/theme.css +4 -4
  32. package/package.json +1 -1
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.7.0] - 2026-10-03
10
+
11
+ ### Changed
12
+
13
+ - Accessibility pass (WCAG 2.2 AA). Fields keep the theme's 2px focus outline instead of hiding it behind a 1px border change. `RangeSlider` thumbs and chips are 24px, the minimum target size. The default `--h1-l` is `76%` (was `70%`), so `h1` text on `b5` clears 4.5:1 at every hue; pink gets a touch lighter at the default hue. `StarRating` picks its text color by contrast (white on the 6.5 to 7 star violet band, where gold was 3.9:1). `Table`'s scrolling wrapper is a focusable `<section>` named by the caption or `scrollLabel`. `SiteFooter` holds its columns in one `<nav>` (`navLabel`, default "Footer"), each column a `<section>` with its title as a heading (`headingLevel`, default 2), instead of a `<nav>` per column. `ReportDisclosure` keeps its status line mounted from the start and moves focus to it after a send. Field errors are `role="status"`, not `role="alert"`; radios no longer carry `aria-invalid` (the group describes the error). Text-only nav entries drop `aria-disabled`. `BeatmapStats` reads full stat names to screen readers.
14
+
15
+ ### Added
16
+
17
+ - `CharCounter`'s `live` prop: announces only the over-limit text.
18
+ - `Table`'s `scrollLabel`, `SiteFooter`'s `navLabel` and `headingLevel`.
19
+ - README: an "Accessibility" section with the house rules every component follows.
20
+
21
+ ## [0.6.0] - 2026-09-28
22
+
23
+ ### Added
24
+
25
+ - `SiteFooter`'s `tools` prop: a "haruhime tools" column linking the other live haruhime.moe tools (packs, pools, bb) and "All tools" on www, with the current tool left out. `HARUHIME_TOOLS` and `haruhimeToolsColumn` export the same data for a custom footer.
26
+
9
27
  ## [0.5.1] - 2026-09-28
10
28
 
11
29
  ### Security
@@ -92,7 +110,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
92
110
  - `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`).
93
111
  - 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).
94
112
 
95
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.5.1...HEAD
113
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.7.0...HEAD
114
+ [0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
115
+ [0.6.0]: https://github.com/haruhimemoe/ui/compare/v0.5.1...v0.6.0
96
116
  [0.5.1]: https://github.com/haruhimemoe/ui/compare/v0.5.0...v0.5.1
97
117
  [0.5.0]: https://github.com/haruhimemoe/ui/compare/v0.4.0...v0.5.0
98
118
  [0.4.0]: https://github.com/haruhimemoe/ui/compare/v0.3.0...v0.4.0
package/README.md CHANGED
@@ -6,7 +6,7 @@ React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-w
6
6
 
7
7
  See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
8
8
 
9
- This README describes version 0.5.0. Anything marked "since 0.5.0" is not in 0.4.0, anything marked "since 0.4.0" is not in 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.
9
+ This README describes version 0.7.0. Anything marked "since 0.7.0" is not in 0.6.0, anything marked "since 0.6.0" is not in 0.5.0, anything marked "since 0.5.0" is not in 0.4.0, anything marked "since 0.4.0" is not in 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.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -80,7 +80,7 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display:
80
80
  At some hues the defaults drop below 4.5:1 contrast, so check yours. Two variables fix it:
81
81
 
82
82
  - `--h2-l` sets the lightness of `h2` (default `45%`). White text on `h2` (primary buttons, the skip link) is under 4.5:1 for hues from about 23 to 205. Use `42%` at hue 200, `35%` at hue 150, or `31%` for any hue.
83
- - `--h1-l` sets the lightness of `h1` (default `70%`). `h1` text on `b4` (card links, "Clear filters") is under 4.5:1 for hues from about 222 to 283. Use `77%` there.
83
+ - `--h1-l` sets the lightness of `h1` (default `76%` since 0.7.0, `70%` before). At `76%`, `h1` text on `b5` (links in cards and prose) is at or above 4.5:1 at every hue. On `b4` ("Clear filters" in a filter panel) it dips under for hues from about 238 to 248. Use `77%` there.
84
84
 
85
85
  The theme is dark only (`color-scheme: dark`).
86
86
 
@@ -234,7 +234,7 @@ An error notice (`role="alert"`) is announced either way.
234
234
 
235
235
  #### `Prose`
236
236
 
237
- 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.
237
+ 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. A `pre` scrolls sideways, so give it `tabIndex={0}` (through your Markdown renderer's `components` map) so keyboard users can reach the scroll; CSS can't add that.
238
238
 
239
239
  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:
240
240
 
@@ -322,7 +322,7 @@ Shared props (type `FieldProps`), taken by `TextInput`, `Textarea`, `Select` and
322
322
  | `id` | `string` | required | The control's id. The label points at it. The hint gets `<id>-hint` and the error `<id>-error`. |
323
323
  | `label` | `ReactNode` | required | The visible label. |
324
324
  | `hint` | `ReactNode` | none | Help text in a `<div>`, linked with `aria-describedby`. On `Checkbox` the hint sits inline inside the label, so keep it to text there. |
325
- | `error` | `ReactNode` | none | Error text in a `role="alert"` `<div>` (`text-rose-300`), so a list of errors is fine. Sets `aria-invalid` and links the text with `aria-describedby`. |
325
+ | `error` | `ReactNode` | none | Error text in a `role="status"` `<div>` (`text-rose-300`; `role="alert"` before 0.7.0), so a list of errors is fine. Sets `aria-invalid` and links the text with `aria-describedby`. |
326
326
  | `wrapperClassName` | `string` | none | Classes for the wrapper around the label, control, hint and error, for layout (`min-w-48 flex-1`). |
327
327
 
328
328
  `className` goes on the control itself. Your own `aria-describedby` is kept after the hint and error ids.
@@ -358,7 +358,7 @@ Since 0.4.0. A native radio group on the `Checkbox` look: a `<fieldset>` named b
358
358
  | `value` / `defaultValue` | `string` | none | The picked value, held by you (`value`, with `onChange`) or by the group (`defaultValue`). |
359
359
  | `onChange` | `(value: string) => void` | none | Gets the picked option's value. |
360
360
  | `name` | `string` | generated | The radios' name, for a form. |
361
- | `hint`, `error` | `ReactNode` | none | Under the options, linked to the group with `aria-describedby`. An error is a `role="alert"` and marks the radios `aria-invalid`. |
361
+ | `hint`, `error` | `ReactNode` | none | Under the options, linked to the group with `aria-describedby`. An error is a `role="status"` line (since 0.7.0; before, `role="alert"` and `aria-invalid` on each radio). |
362
362
  | `required` | `boolean` | `false` | Every radio gets `required`. |
363
363
 
364
364
  #### `TypeToConfirm` (client)
@@ -386,11 +386,14 @@ Since 0.4.0. A confirm for something that can't be undone: a `<form>` whose subm
386
386
 
387
387
  Since 0.5.0. How much of a length limit a text uses: "1,234 / 60,000 characters" in `c3`, then bold rose with ": 1,500 over the limit" once past it (at the limit is not over). You count, so any rule works (a string's length, a BBCode counter). Every native `<p>` prop except `children`.
388
388
 
389
+ With `live` (since 0.7.0) an `<output>` live region inside it announces "1,500 over the limit" when the count goes over and nothing while under, so typing isn't read out number by number.
390
+
389
391
  | Prop | Type | Default | What it does |
390
392
  | --- | --- | --- | --- |
391
393
  | `count` | `number` | required | How many are used. |
392
394
  | `limit` | `number` | required | The most allowed. |
393
395
  | `unit` | `string` | `"characters"` | What is counted. |
396
+ | `live` | `boolean` | `false` | Announce the over-limit text to screen readers (since 0.7.0). |
394
397
 
395
398
  #### `VisibilitySelect` (client)
396
399
 
@@ -420,7 +423,7 @@ Since 0.5.0. Who can see something: private, unlisted or public, each with a lin
420
423
 
421
424
  #### `ReportDisclosure` (client)
422
425
 
423
- Since 0.5.0. "Report this": a `Disclosure` holding a reason `Textarea` (required) and a submit button. `onSubmit` gets the trimmed reason and says how it went: `{ ok: true, message? }` replaces the form with a `role="status"` line (your message, like "You already reported it.", or `sentMessage`); `{ ok: false, message }` shows the message as the field's error and keeps what was typed for a retry. A throw reads as `failedMessage`. One send at a time. Type: `ReportResult`.
426
+ Since 0.5.0. "Report this": a `Disclosure` holding a reason `Textarea` (required) and a submit button. `onSubmit` gets the trimmed reason and says how it went: `{ ok: true, message? }` replaces the form with a `role="status"` line (your message, like "You already reported it.", or `sentMessage`), which takes focus, since the button that had it is gone (since 0.7.0; the line is mounted empty from the start, so it is announced); `{ ok: false, message }` shows the message as the field's error and keeps what was typed for a retry. A throw reads as `failedMessage`. One send at a time. Type: `ReportResult`.
424
427
 
425
428
  | Prop | Type | Default | What it does |
426
429
  | --- | --- | --- | --- |
@@ -433,7 +436,7 @@ Since 0.5.0. "Report this": a `Disclosure` holding a reason `Textarea` (required
433
436
  | `failedMessage` | `ReactNode` | `"Couldn't send the report. Try again."` | Said when `onSubmit` throws. |
434
437
  | `minLength`, `maxLength` | `number` | `3`, none | The reason's length limits. |
435
438
  | `rows` | `number` | `3` | The field's height. |
436
- | `className` | `string` | none | Classes for the wrapper (or the status line once sent). |
439
+ | `className` | `string` | none | Classes for the wrapper around the disclosure and the status line. |
437
440
 
438
441
  ```tsx
439
442
  <ReportDisclosure
@@ -448,7 +451,7 @@ Since 0.5.0. "Report this": a `Disclosure` holding a reason `Textarea` (required
448
451
 
449
452
  #### `fieldClasses`
450
453
 
451
- `fieldClasses(className?: string): string` returns the field look (`b6` background, `b3` border, `h1` border on focus, rose border when `aria-invalid`, and an `h1` border and ring when an invalid field has focus). Use it on a bare control that labels itself:
454
+ `fieldClasses(className?: string): string` returns the field look (`b6` background, `b3` border, `h1` border plus the theme's 2px `h1` outline on focus, rose border when `aria-invalid`, and the `h1` border back when an invalid field has focus; before 0.7.0 the outline was hidden and only the border changed). Use it on a bare control that labels itself:
452
455
 
453
456
  ```tsx
454
457
  <select aria-label="Move to" className={fieldClasses("w-auto")}>...</select>
@@ -750,7 +753,7 @@ The `<ul>` of links `SiteHeader` uses, for building your own header. Put it insi
750
753
 
751
754
  | Prop | Type | Default | What it does |
752
755
  | --- | --- | --- | --- |
753
- | `links` | `readonly SiteLinkItem[]` | required | The entries. |
756
+ | `links` | `readonly SiteLinkItem[]` | required | The entries. An entry without an `href` renders as dimmed text with its `note` (no `aria-disabled` since 0.7.0: it isn't a control). |
754
757
  | `align` | `"start" \| "center"` | `"start"` | As `navAlign` on `SiteHeader`. |
755
758
 
756
759
  #### `SiteFooter`
@@ -759,7 +762,10 @@ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub ico
759
762
 
760
763
  | Prop | Type | Default | What it does |
761
764
  | --- | --- | --- | --- |
762
- | `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. |
765
+ | `columns` | `readonly SiteFooterColumn[]` | `[]` | The columns sit in one `<nav>` (`navLabel`); each is a `<section>` headed by its title. Up to four columns side by side from `sm` up. Before 0.7.0 each column was its own `<nav>`. |
766
+ | `navLabel` | `string` | `"Footer"` | The nav landmark's name. Keep it different from `SiteHeader`'s `navLabel`, so the two landmarks tell apart (since 0.7.0). |
767
+ | `headingLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `2` | The column titles' heading level (since 0.7.0). |
768
+ | `tools` | `HaruhimeToolsOptions` | none | Adds a "haruhime tools" column: the other live haruhime.moe tools as "name: blurb" links, then "All tools" on www. Options: `current` (the tool this footer is on, left out), `title` (`"haruhime tools"`), `allLabel` (`"All tools"`, `false` drops it), `allHref` (`"https://www.haruhime.moe"`), `position` (where among `columns`, default `1`, clamped). Since 0.6.0. |
763
769
  | `extra` | `ReactNode` | none | Shown above the fine print, e.g. a "clear local data" button. |
764
770
  | `finePrint` | `ReactNode` | none | One line of small print, in a `<p>`. |
765
771
  | `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. |
@@ -769,6 +775,8 @@ Link columns, an extra slot, fine print, the haruhime.moe wordmark, a GitHub ico
769
775
  | `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. Since 0.3.0. |
770
776
  | `discordLabel` | `string` | `"Discord"` | The Discord link's accessible name. Since 0.4.0. |
771
777
 
778
+ `HARUHIME_TOOLS` (each `{ id, name, href, blurb }`, type `HaruhimeTool`, ids `HaruhimeToolId`: `"packs" | "pools" | "bb"`) and `haruhimeToolsColumn(options)` (the same column as plain data, for a footer you lay out yourself) are exported too. Since 0.6.0. They are the one place outside the wordmark that names the haruhime.moe tools: a tool joins the list when it goes live.
779
+
772
780
  #### `LinkTabs`
773
781
 
774
782
  Since 0.4.0. A row of link tabs (Pools / Maps, All / Hidden): a named `<nav>` with a list of pill links. The current one gets `aria-current="page"` and a `b3` pill (underlined in forced colors mode). These are links, not ARIA tabs, since each loads its own URL. A Server Component: you say which is current. Every native `<nav>` prop except `children`. Type: `LinkTabItem`.
@@ -810,7 +818,7 @@ Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (
810
818
 
811
819
  #### `StarRating`
812
820
 
813
- A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rating's color, dark text up to 6.5 and pale yellow above. The spectrum is osu!'s own, the same at every `--hue`, so the pill sets its colors inline. Screen readers hear "5.23 stars" and the `label` after it. Every native `<span>` prop except `children`; a `style` you pass merges over the colors.
821
+ A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rating's color, dark text up to 6.5 and osu!'s pale yellow above, except where neither clears 4.5:1 on the pill (the violet band around 6.5 to 7 stars), which gets white (since 0.7.0). The spectrum is osu!'s own, the same at every `--hue`, so the pill sets its colors inline. Screen readers hear "5.23 stars" and the `label` after it. Every native `<span>` prop except `children`; a `style` you pass merges over the colors.
814
822
 
815
823
  | Prop | Type | Default | What it does |
816
824
  | --- | --- | --- | --- |
@@ -820,7 +828,7 @@ A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rati
820
828
 
821
829
  #### `BeatmapStats`
822
830
 
823
- A beatmap's CS, AR, OD, HP, BPM and length as a compact `<dl>`, in that order. Stats you leave out (or that aren't finite) don't show. CS, AR, OD, HP and BPM are `<abbr>`s titled with their full names. Every native `<dl>` prop except `children`. Type: `BeatmapStatKey`.
831
+ A beatmap's CS, AR, OD, HP, BPM and length as a compact `<dl>`, in that order. Stats you leave out (or that aren't finite) don't show. CS, AR, OD, HP and BPM are `<abbr>`s titled with their full names; screen readers get the full name instead of the letters (since 0.7.0). Every native `<dl>` prop except `children`. Type: `BeatmapStatKey`.
824
832
 
825
833
  | Prop | Type | Default | What it does |
826
834
  | --- | --- | --- | --- |
@@ -862,7 +870,7 @@ Since 0.4.0. `Table`, `THead`, `TBody`, `Th` and `Td` give a data table the apps
862
870
 
863
871
  | Component | Extra props | What it renders |
864
872
  | --- | --- | --- |
865
- | `Table` | `caption?: ReactNode`, `hideCaption?: boolean` (default `false`), `wrapperClassName?: string` | A `<div>` that scrolls sideways on phones, around the `<table>`. `className` and `ref` go on the `<table>`. The caption names the table; `hideCaption` keeps it for screen readers only, when a heading already shows. |
873
+ | `Table` | `caption?: ReactNode`, `hideCaption?: boolean` (default `false`), `wrapperClassName?: string`, `scrollLabel?: string` (default `"Table"`) | A `<section>` that scrolls sideways on phones, around the `<table>`. It takes keyboard focus (`tabindex="0"`) so the scroll is reachable without a mouse, and is named by the caption, or by `scrollLabel` without one (since 0.7.0; a plain `<div>` before). `className` and `ref` go on the `<table>`. The caption names the table; `hideCaption` keeps it for screen readers only, when a heading already shows. |
866
874
  | `THead` | none | A `<thead>` in `c3`, `text-xs`, uppercase. |
867
875
  | `TBody` | none | A `<tbody>` whose rows get a `b4` top border. Add `[&>tr]:align-top` for rows of mixed height. |
868
876
  | `Th` | `numeric?: boolean` | A `<th>` with `scope="col"` by default. With `scope="row"` it is a row's heading, in bold `c1`. |
@@ -878,18 +886,20 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
878
886
 
879
887
  ## Accessibility
880
888
 
881
- - 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).
882
- - 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.
883
- - In forced colors mode, a pressed `Chip` takes the system highlight colors, so on and off still look different.
884
- - Form fields link their label, hint and error. An error sets `aria-invalid` and is announced.
885
- - `Chip` uses `aria-pressed`. `ChipGroup`, `RangeSlider` and `FilterRow` are fieldsets named by their label. Inside a `FilterRow`, `hideLabel` leaves the naming to the row, so each row is announced once. `RangeSlider`'s thumbs are native range inputs with `aria-valuetext`, so "10+" reads as it shows.
886
- - `FilterPanel`'s phone toggle carries `aria-expanded` and `aria-controls`. The result count is a live region. When "Clear filters" disappears after use, focus moves to the panel's heading instead of getting lost.
887
- - `CopyButton` announces "Copied." (or the failure) through an `<output>`, on every press.
888
- - `Pagination` moves focus to its "Page X of Y" text when the link you pressed goes away on the first or last page.
889
- - `Tabs` follows the ARIA tabs pattern: one tab in the Tab order, arrows, Home and End to move, `aria-controls` to its panel. `ReportDisclosure` says the outcome in a `role="status"` line and keeps a failed reason in the field.
890
- - `SiteHeader` marks the current page with `aria-current`. `PageShell` starts with a skip link to `<main>`.
891
- - `DiscordIcon` and `GitHubIcon` are hidden from screen readers by default: give the link around each one an `aria-label`, as `SiteFooter` does.
892
- - You supply the text, so you also supply labels: give icon-only buttons an `aria-label`, and keep `label` props meaningful.
889
+ The target is WCAG 2.2 AA. House rules, which every component follows and your own code around them should too:
890
+
891
+ - **Focus is always visible, and always the same.** The theme draws a 2px `h1` outline, offset 2px, on `:focus-visible`. Fields keep it (since 0.7.0; before, they swapped it for a 1px border change) and add an `h1` border. `RangeSlider` thumbs show a solid `h1` ring instead and keep a transparent outline, so Windows high contrast mode (forced colors) still paints one. Never `outline: none`.
892
+ - **Targets are 24px or more** (WCAG 2.2 2.5.8): buttons are `h-9`, chips and tabs 24px tall, slider thumbs 24px (since 0.7.0).
893
+ - **Contrast is computed, not eyeballed.** The test suite checks `c1` on `h2`, `h1` on `b4` and `b5`, and the hue overrides under Setup; `StarRating` picks its text color by contrast. Text is never dimmed with `opacity` (a `text-c4` line at 70% opacity drops under 4.5:1): use a lighter palette step instead.
894
+ - **Color never carries meaning alone.** Accent links are underlined, a pressed `Chip` is `aria-pressed`, the current nav link is `aria-current`, `CharCounter` says "over the limit" in words, errors are text.
895
+ - **Live regions exist before they speak.** `CopyButton`, `AsyncButton`, `CharCounter live`, `FilterPanel`'s count and `ReportDisclosure`'s outcome render their `<output>` or `role="status"` node up front, empty, and swap the text in. Field errors are `role="status"` (polite); `Notice live tone="error"` is the one `role="alert"`.
896
+ - **Focus never falls to the body.** When the control you pressed goes away, focus moves somewhere sensible: `FilterPanel` to its heading, `Pagination` to "Page X of Y", `InlineConfirm` back to its trigger, `ReportDisclosure` to its outcome line. Pending buttons use `aria-disabled`, not `disabled`, so focus stays.
897
+ - **Landmarks are few and named.** `PageShell` gives a skip link and `<main>`; `SiteHeader` one `<nav>` (`navLabel`, default "Main"); `SiteFooter` one `<nav>` (`navLabel`, default "Footer") with a headed `<section>` per column; `Pagination` and `LinkTabs` are labelled `<nav>`s. Give two of a kind different labels.
898
+ - **Scrollable regions take focus.** `Table`'s wrapper is a focusable named section. Do the same for a `pre` inside `Prose`.
899
+ - **Native first.** Fields are native inputs, selects and textareas; radios and checkboxes are native with a visible label; `RangeSlider`'s thumbs are native range inputs with `aria-valuetext` ("10+" reads as it shows); `Disclosure` and `HeaderMenu` are buttons with `aria-expanded` and `aria-controls`; `Tabs` follows the ARIA tabs pattern (one tab in the Tab order, arrows, Home and End, `aria-controls`). ARIA only where HTML has no element.
900
+ - **Groups are named.** `ChipGroup`, `RangeSlider`, `RadioGroup` and `FilterRow` are fieldsets named by their label. Inside a `FilterRow`, `hideLabel` leaves the naming to the row, so each row is announced once.
901
+ - **Icons are decorative; links and buttons have names.** `DiscordIcon` and `GitHubIcon` are hidden from screen readers; give the link around each one an `aria-label`, as `SiteFooter` does. Give icon-only buttons an `aria-label`, and keep `label` props meaningful. `BeatmapStats` reads "Circle size" where it shows "CS".
902
+ - **Tested.** Every component is checked with axe-core's WCAG 2.0, 2.1 and 2.2 A and AA rules in the test suite (color contrast excepted: that needs a real browser, and the sites run a Playwright axe pass with it on). Interactive ones also have keyboard tests.
893
903
 
894
904
  ## Compatibility
895
905
 
@@ -7,7 +7,7 @@
7
7
  * lives in rangeMath.ts and the boxes in RangeBox.tsx.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
11
11
  */
12
12
  import type { HTMLAttributes } from "react";
13
13
  import { type GroupFrameProps } from "./GroupFrame.js";
@@ -7,7 +7,7 @@
7
7
  * lives in rangeMath.ts and the boxes in RangeBox.tsx.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
11
11
  */
12
12
  "use client";
13
13
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -17,12 +17,12 @@ import { GroupFrame } from "./GroupFrame.js";
17
17
  import { RangeBox } from "./RangeBox.js";
18
18
  import { defaultParse, dragEnd, nextRange, normalizeRange, percent, rangeBounds, readDraft, thumbTarget, } from "./rangeMath.js";
19
19
  // Native range inputs, stacked on one track. Only the thumbs take the pointer, so either thumb
20
- // can be dragged wherever they sit. The focus ring goes on the thumb, not the full-width input:
20
+ // can be dragged wherever they sit. Thumbs are 24px (size-6): WCAG 2.2's minimum target size. The focus ring goes on the thumb, not the full-width input:
21
21
  // solid h1, so it clears 3:1 on the panel. Forced-colors mode drops box-shadow rings, so the
22
22
  // input keeps a transparent outline (`outline-hidden`) that mode paints instead.
23
- const RANGE = "pointer-events-none absolute inset-x-0 top-1/2 h-4 w-full -translate-y-1/2 appearance-none bg-transparent focus-visible:outline-hidden disabled:cursor-not-allowed " +
24
- "[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:box-border [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-h1 [&::-webkit-slider-thumb]:bg-c1 " +
25
- "[&::-moz-range-track]:bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:box-border [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-h1 [&::-moz-range-thumb]:bg-c1 " +
23
+ const RANGE = "pointer-events-none absolute inset-x-0 top-1/2 h-6 w-full -translate-y-1/2 appearance-none bg-transparent focus-visible:outline-hidden disabled:cursor-not-allowed " +
24
+ "[&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-6 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:box-border [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-h1 [&::-webkit-slider-thumb]:bg-c1 " +
25
+ "[&::-moz-range-track]:bg-transparent [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-6 [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:box-border [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-h1 [&::-moz-range-thumb]:bg-c1 " +
26
26
  "focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-h1 focus-visible:[&::-moz-range-thumb]:ring-4 focus-visible:[&::-moz-range-thumb]:ring-h1";
27
27
  /**
28
28
  * @function RangeSlider
@@ -94,5 +94,5 @@ export function RangeSlider({ label, min: rawMin, max: rawMax, step: rawStep = 1
94
94
  const box = (end) => (_jsx(RangeBox, { label: end === "low" ? minLabel : maxLabel, text: end === "low" ? format(low) : highOpen ? openText : format(high), inputMode: inputMode, disabled: disabled, onCommit: commitDraft(end) }));
95
95
  const lowPercent = percent(low, bounds);
96
96
  const highPercent = highOpen ? 100 : percent(high, bounds);
97
- return (_jsx(GroupFrame, { label: label, disabled: disabled, className: cx("disabled:opacity-50", className), ...props, children: _jsxs("div", { className: "flex items-center gap-3", children: [box("low"), _jsxs("div", { className: "relative h-5 min-w-0 flex-1", children: [_jsx("div", { "aria-hidden": "true", className: "absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-b3" }), _jsx("div", { "aria-hidden": "true", className: "absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2", children: _jsx("div", { "data-range-fill": "", className: "absolute inset-y-0 rounded-full bg-h1 forced-colors:bg-[Highlight]", style: { left: `${lowPercent}%`, right: `${100 - highPercent}%` } }) }), _jsx("input", { ...thumb("low"), className: cx(RANGE, lowPercent > 50 && "z-10") }), _jsx("input", { ...thumb("high"), className: RANGE })] }), box("high")] }) }));
97
+ return (_jsx(GroupFrame, { label: label, disabled: disabled, className: cx("disabled:opacity-50", className), ...props, children: _jsxs("div", { className: "flex items-center gap-3", children: [box("low"), _jsxs("div", { className: "relative h-6 min-w-0 flex-1", children: [_jsx("div", { "aria-hidden": "true", className: "absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-b3" }), _jsx("div", { "aria-hidden": "true", className: "absolute inset-x-3 top-1/2 h-1.5 -translate-y-1/2", children: _jsx("div", { "data-range-fill": "", className: "absolute inset-y-0 rounded-full bg-h1 forced-colors:bg-[Highlight]", style: { left: `${lowPercent}%`, right: `${100 - highPercent}%` } }) }), _jsx("input", { ...thumb("low"), className: cx(RANGE, lowPercent > 50 && "z-10") }), _jsx("input", { ...thumb("high"), className: RANGE })] }), box("high")] }) }));
98
98
  }
@@ -5,10 +5,10 @@
5
5
  * colors, so on and off still look different.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  /** The pill: rounded, px-2.5 py-0.5, bold text-xs. */
11
- export declare const CHIP = "rounded-full px-2.5 py-0.5 font-bold text-xs transition-colors";
11
+ export declare const CHIP = "rounded-full px-2.5 py-1 font-bold text-xs transition-colors";
12
12
  /** A chip that is on (pressed or checked). */
13
13
  export declare const CHIP_ON = "bg-h1 text-b6 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
14
14
  /** A chip that is off. */
@@ -5,10 +5,11 @@
5
5
  * colors, so on and off still look different.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  /** The pill: rounded, px-2.5 py-0.5, bold text-xs. */
11
- export const CHIP = "rounded-full px-2.5 py-0.5 font-bold text-xs transition-colors";
11
+ // py-1 with the text-xs line height makes a 24px pill: WCAG 2.2's minimum target size.
12
+ export const CHIP = "rounded-full px-2.5 py-1 font-bold text-xs transition-colors";
12
13
  /** A chip that is on (pressed or checked). */
13
14
  export const CHIP_ON = "bg-h1 text-b6 forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]";
14
15
  /** A chip that is off. */
@@ -5,7 +5,7 @@
5
5
  * length), so it fits any rule. Server-safe. Moved from bb.haruhime.moe.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  import type { ComponentProps } from "react";
11
11
  /** Every native `<p>` prop except children, plus the count, the limit and the unit. */
@@ -16,10 +16,12 @@ export type CharCounterProps = Omit<ComponentProps<"p">, "children"> & {
16
16
  limit: number;
17
17
  /** What is counted (default "characters"). */
18
18
  unit?: string | undefined;
19
+ /** Announce "N over the limit" to screen readers when the count goes over (and silence when it comes back). */
20
+ live?: boolean | undefined;
19
21
  };
20
22
  /**
21
23
  * @function CharCounter
22
24
  * @param props {CharCounterProps} the count, the limit, the unit, and native `<p>` props
23
25
  * @returns {JSX.Element} "count / limit unit" in c3, or in bold rose with ": N over the limit"
24
26
  */
25
- export declare function CharCounter({ count, limit, unit, className, ...props }: CharCounterProps): import("react").JSX.Element;
27
+ export declare function CharCounter({ count, limit, unit, live, className, ...props }: CharCounterProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../../utils/cx.js";
3
3
  const format = (value) => value.toLocaleString("en-US");
4
4
  /**
@@ -6,7 +6,9 @@ const format = (value) => value.toLocaleString("en-US");
6
6
  * @param props {CharCounterProps} the count, the limit, the unit, and native `<p>` props
7
7
  * @returns {JSX.Element} "count / limit unit" in c3, or in bold rose with ": N over the limit"
8
8
  */
9
- export function CharCounter({ count, limit, unit = "characters", className, ...props }) {
9
+ export function CharCounter({ count, limit, unit = "characters", live = false, className, ...props }) {
10
10
  const over = count - limit;
11
- return (_jsxs("p", { className: cx("text-sm tabular-nums", over > 0 ? "font-bold text-rose-300" : "text-c3", className), ...props, children: [`${format(count)} / ${format(limit)} ${unit}`, over > 0 ? `: ${format(over)} over the limit` : ""] }));
11
+ const overText = over > 0 ? `${format(over)} over the limit` : "";
12
+ const shown = `${format(count)} / ${format(limit)} ${unit}${over > 0 ? `: ${overText}` : ""}`;
13
+ return (_jsx("p", { className: cx("text-sm tabular-nums", over > 0 ? "font-bold text-rose-300" : "text-c3", className), ...props, children: live ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", children: shown }), _jsx("output", { "aria-live": "polite", className: "sr-only", children: overText })] })) : (shown) }));
12
14
  }
@@ -7,7 +7,7 @@
7
7
  * aria-invalid.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
11
11
  */
12
12
  import type { ComponentProps, ReactNode } from "react";
13
13
  /** The label, hint and error every form field takes, keyed on the control's required id. */
@@ -44,7 +44,7 @@ export const fieldControlProps = ({ id, hint, error, describedBy, invalid, }) =>
44
44
  * nothing without an error
45
45
  */
46
46
  export function FieldError({ id, error }) {
47
- return error ? (_jsx("div", { id: errorId(id), role: "alert", className: "text-rose-300 text-sm", children: error })) : null;
47
+ return error ? (_jsx("div", { id: errorId(id), role: "status", className: "text-rose-300 text-sm", children: error })) : null;
48
48
  }
49
49
  /**
50
50
  * @function FieldFrame
@@ -7,7 +7,7 @@
7
7
  * native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Mon Sep 28, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
11
11
  */
12
12
  import { type ComponentProps, type ReactNode } from "react";
13
13
  /** One radio: its value, its label, an optional inline hint, and whether it can be picked. */
@@ -7,7 +7,7 @@
7
7
  * native radios do. Controlled (`value`) or uncontrolled (`defaultValue`).
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Mon Sep 28, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
11
11
  */
12
12
  "use client";
13
13
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -27,6 +27,6 @@ export function RadioGroup({ label, options, name, value, defaultValue, onChange
27
27
  const optionId = `${id}-${i}`;
28
28
  return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "radio", id: optionId, name: name ?? id, value: option.value, ...(value === undefined
29
29
  ? { defaultChecked: option.value === defaultValue }
30
- : { checked: option.value === value }), disabled: option.disabled, required: required, "aria-invalid": error ? true : undefined, "aria-labelledby": `${optionId}-label`, "aria-describedby": option.hint ? hintId(optionId) : undefined, onChange: () => onChange?.(option.value), className: "mt-1 accent-h1" }), _jsxs("span", { children: [_jsx("span", { id: `${optionId}-label`, className: "font-bold text-c1", children: option.label }), option.hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(optionId), children: option.hint })] })) : null] })] }, option.value));
30
+ : { checked: option.value === value }), disabled: option.disabled, required: required, "aria-labelledby": `${optionId}-label`, "aria-describedby": option.hint ? hintId(optionId) : undefined, onChange: () => onChange?.(option.value), className: "mt-1 accent-h1" }), _jsxs("span", { children: [_jsx("span", { id: `${optionId}-label`, className: "font-bold text-c1", children: option.label }), option.hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(optionId), children: option.hint })] })) : null] })] }, option.value));
31
31
  }), hint ? (_jsx("div", { id: hintId(id), className: "text-c4 text-xs", children: hint })) : null, _jsx(FieldError, { id: id, error: error })] }));
32
32
  }
@@ -6,7 +6,7 @@
6
6
  * at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  import { type ReactNode } from "react";
12
12
  /** What `onSubmit` says: done (with what to say) or not (with the error). */
@@ -6,11 +6,11 @@
6
6
  * at a time; a throw reads as `failedMessage`. Moved from bb.haruhime.moe (ReportForm).
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  "use client";
12
12
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
- import { useId, useRef, useState } from "react";
13
+ import { useEffect, useId, useRef, useState } from "react";
14
14
  import { cx } from "../../utils/cx.js";
15
15
  import { Button } from "../basics/Button.js";
16
16
  import { Disclosure } from "../basics/Disclosure.js";
@@ -27,9 +27,13 @@ export function ReportDisclosure({ onSubmit, summary = "Report", label = "What's
27
27
  const [error, setError] = useState(null);
28
28
  const [done, setDone] = useState(null);
29
29
  const running = useRef(false);
30
- if (done !== null) {
31
- return (_jsx("p", { role: "status", className: cx("text-c2 text-sm", className), children: done }));
32
- }
30
+ const status = useRef(null);
31
+ // The form goes away with the button that had focus, so focus lands on the outcome instead
32
+ // of falling to the body.
33
+ useEffect(() => {
34
+ if (done !== null)
35
+ status.current?.focus();
36
+ }, [done]);
33
37
  const send = async (event) => {
34
38
  event.preventDefault();
35
39
  if (running.current)
@@ -50,5 +54,5 @@ export function ReportDisclosure({ onSubmit, summary = "Report", label = "What's
50
54
  running.current = false;
51
55
  setPending(false);
52
56
  };
53
- return (_jsx(Disclosure, { summary: summary, className: className, children: _jsxs("form", { className: "flex flex-col gap-2", onSubmit: send, children: [_jsx(Textarea, { id: `${id}-reason`, label: label, hint: hint, error: error ?? undefined, value: reason, rows: rows, minLength: minLength, maxLength: maxLength, required: true, onChange: (event) => setReason(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: "secondary", disabled: pending, className: "self-start", children: pending ? pendingLabel : submitLabel })] }) }));
57
+ return (_jsxs("div", { className: cx("flex flex-col gap-2", className), children: [done === null ? (_jsx(Disclosure, { summary: summary, children: _jsxs("form", { className: "flex flex-col gap-2", onSubmit: send, children: [_jsx(Textarea, { id: `${id}-reason`, label: label, hint: hint, error: error ?? undefined, value: reason, rows: rows, minLength: minLength, maxLength: maxLength, required: true, onChange: (event) => setReason(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: "secondary", disabled: pending, className: "self-start", children: pending ? pendingLabel : submitLabel })] }) })) : null, _jsx("p", { ref: status, role: "status", tabIndex: -1, className: done === null ? "sr-only" : "text-c2 text-sm", children: done })] }));
54
58
  }
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * @file src/components/forms/fieldStyles.ts
3
- * @desc Shared classes for inputs, selects, and textareas (osu!-web dark fields). Focus shows as
4
- * an h1 border. On an invalid field it also gets an h1 ring, since rose to pink alone is
5
- * hard to see. `outline-hidden` (not `outline-none`) leaves a transparent outline that
6
- * forced-colors mode paints, so focus shows there too. Also the one label look that fields,
7
- * groups and filter rows share.
3
+ * * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
4
+ * the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
5
+ * every control gets, plus an h1 border; an invalid field keeps its rose border until it
6
+ * takes focus, when the h1 border and ring take over so focus stays visible.
8
7
  * @author David @dvhsh (https://dvh.sh)
9
8
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
11
10
  */
12
11
  /** A field or group label: bold c3 at text-sm (internal). */
13
12
  export declare const FIELD_LABEL = "font-bold text-c3 text-sm";
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * @file src/components/forms/fieldStyles.ts
3
- * @desc Shared classes for inputs, selects, and textareas (osu!-web dark fields). Focus shows as
4
- * an h1 border. On an invalid field it also gets an h1 ring, since rose to pink alone is
5
- * hard to see. `outline-hidden` (not `outline-none`) leaves a transparent outline that
6
- * forced-colors mode paints, so focus shows there too. Also the one label look that fields,
7
- * groups and filter rows share.
3
+ * * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
4
+ * the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
5
+ * every control gets, plus an h1 border; an invalid field keeps its rose border until it
6
+ * takes focus, when the h1 border and ring take over so focus stays visible.
8
7
  * @author David @dvhsh (https://dvh.sh)
9
8
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
11
10
  */
12
11
  import { cx } from "../../utils/cx.js";
13
- const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 focus-visible:outline-hidden disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1 aria-invalid:focus-visible:ring-1 aria-invalid:focus-visible:ring-h1";
12
+ const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1";
14
13
  /** A field or group label: bold c3 at text-sm (internal). */
15
14
  export const FIELD_LABEL = "font-bold text-c3 text-sm";
16
15
  /**
@@ -5,7 +5,7 @@
5
5
  * not finite doesn't show. The short names are `<abbr>`s with the full name as a title.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  import type { ComponentProps, ReactNode } from "react";
11
11
  /** The stats BeatmapStats shows, in order. */
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from "../../utils/cx.js";
3
- const abbr = (short, full) => _jsx("abbr", { title: full, children: short });
3
+ // The short form shows (and titles a tooltip); screen readers get the full name instead.
4
+ const abbr = (short, full) => (_jsxs("abbr", { title: full, children: [_jsx("span", { "aria-hidden": "true", children: short }), _jsx("span", { className: "sr-only", children: full })] }));
4
5
  const LABELS = {
5
6
  cs: abbr("CS", "Circle size"),
6
7
  ar: abbr("AR", "Approach rate"),
@@ -5,8 +5,9 @@
5
5
  * They are the same at every --hue, like the wordmark's brand colors.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
+ type Rgb = readonly [number, number, number];
10
11
  /**
11
12
  * @function starRatingColor
12
13
  * @param stars {number} a star rating
@@ -14,10 +15,19 @@
14
15
  * straight blend between the two stops around it otherwise
15
16
  */
16
17
  export declare const starRatingColor: (stars: number) => string;
18
+ /**
19
+ * @function contrastRatio
20
+ * @param a {Rgb} one color
21
+ * @param b {Rgb} the other
22
+ * @returns {number} the WCAG contrast ratio between them, 1 to 21
23
+ */
24
+ export declare const contrastRatio: (a: Rgb, b: Rgb) => number;
17
25
  /**
18
26
  * @function starRatingTextColor
19
- * @param stars {number} a star rating
20
- * @returns {string} text that reads on `starRatingColor(stars)`: black up to 6.5, then osu!'s pale
21
- * yellow on the dark end of the spectrum
27
+ * @param stars {number} the rating
28
+ * @returns {string} the text color on {@link starRatingColor}'s pill: osu!'s black up to the
29
+ * dark end, its pale gold from there, white only where neither clears 4.5:1 (the violet
30
+ * band around 6.5 to 7 stars)
22
31
  */
23
32
  export declare const starRatingTextColor: (stars: number) => string;
33
+ export {};
@@ -5,7 +5,7 @@
5
5
  * They are the same at every --hue, like the wordmark's brand colors.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  const STOPS = [
11
11
  [0.1, [66, 144, 251]],
@@ -45,4 +45,41 @@ export const starRatingColor = (stars) => {
45
45
  * @returns {string} text that reads on `starRatingColor(stars)`: black up to 6.5, then osu!'s pale
46
46
  * yellow on the dark end of the spectrum
47
47
  */
48
- export const starRatingTextColor = (stars) => stars >= 6.5 ? css([255, 217, 102]) : css([0, 0, 0]);
48
+ const BLACK = [0, 0, 0];
49
+ const GOLD = [255, 217, 102];
50
+ const WHITE = [255, 255, 255];
51
+ const luminance = ([r, g, b]) => {
52
+ const channel = (v) => {
53
+ const c = v / 255;
54
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
55
+ };
56
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
57
+ };
58
+ /**
59
+ * @function contrastRatio
60
+ * @param a {Rgb} one color
61
+ * @param b {Rgb} the other
62
+ * @returns {number} the WCAG contrast ratio between them, 1 to 21
63
+ */
64
+ export const contrastRatio = (a, b) => {
65
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
66
+ return (light + 0.05) / (dark + 0.05);
67
+ };
68
+ const parse = (color) => (color.match(/\d+/g) ?? ["0", "0", "0"]).slice(0, 3).map(Number);
69
+ /**
70
+ * @function starRatingTextColor
71
+ * @param stars {number} the rating
72
+ * @returns {string} the text color on {@link starRatingColor}'s pill: osu!'s black up to the
73
+ * dark end, its pale gold from there, white only where neither clears 4.5:1 (the violet
74
+ * band around 6.5 to 7 stars)
75
+ */
76
+ export const starRatingTextColor = (stars) => {
77
+ const pill = parse(starRatingColor(stars));
78
+ if (stars < 6.5 && contrastRatio(BLACK, pill) >= 4.5)
79
+ return css(BLACK);
80
+ if (contrastRatio(GOLD, pill) >= 4.5)
81
+ return css(GOLD);
82
+ if (contrastRatio(BLACK, pill) >= 4.5)
83
+ return css(BLACK);
84
+ return css(WHITE);
85
+ };
@@ -5,7 +5,7 @@
5
5
  * browser, so both lists print the same markup.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Thu Sep 24, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
10
  import type { SiteLinkItem } from "./links.js";
11
11
  /** One nav entry, its aria-current and the finished classes for its link. */
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * browser, so both lists print the same markup.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Thu Sep 24, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  import { AutoLink } from "../basics/AutoLink.js";
12
12
  import { LinkNote } from "./LinkNote.js";
@@ -17,7 +17,7 @@ import { LinkNote } from "./LinkNote.js";
17
17
  */
18
18
  export function NavItem({ item, current, linkClassName }) {
19
19
  if (!item.href) {
20
- return (_jsx("li", { children: _jsxs("span", { "aria-disabled": "true", className: "text-c4", children: [item.label, _jsx(LinkNote, { note: item.note })] }) }));
20
+ return (_jsx("li", { children: _jsxs("span", { className: "text-c4", children: [item.label, _jsx(LinkNote, { note: item.note })] }) }));
21
21
  }
22
22
  return (_jsx("li", { children: _jsx(AutoLink, { href: item.href, "aria-current": current, className: linkClassName, children: item.label }) }));
23
23
  }
@@ -1,16 +1,19 @@
1
1
  /**
2
2
  * @file src/components/shell/SiteFooter.tsx
3
- * @desc Site footer: labelled link columns from data (entries without an href show as text with
4
- * a small note, like "soon"), an optional extra slot, one line of fine print, the
3
+ * @desc Site footer: link columns from data under one nav landmark, each a section headed by its title (entries without an href show as text with
4
+ * a small note, like "soon"), an optional "haruhime tools" column linking the other tools,
5
+ * an optional extra slot, one line of fine print, the
5
6
  * haruhime.moe wordmark linking the parent site, a GitHub icon link, and an optional
6
7
  * Discord icon link beside it (white, as Discord's brand guidelines ask).
7
8
  * @author David @dvhsh (https://dvh.sh)
8
9
  * @created Wed Sep 23, 2026
9
- * @modified Mon Sep 28, 2026
10
+ * @modified Sat Oct 3, 2026
10
11
  */
11
12
  import type { ComponentProps, ReactNode } from "react";
13
+ import type { HeadingLevel } from "../basics/cardStyles.js";
14
+ import { type HaruhimeToolsOptions } from "./haruhimeTools.js";
12
15
  import { type SiteLinkItem } from "./links.js";
13
- /** One footer column: a title (also the nav landmark's name) and its entries. */
16
+ /** One footer column: a title (its heading, which names the column's section) and its entries. */
14
17
  export type SiteFooterColumn = {
15
18
  title: string;
16
19
  items: readonly SiteLinkItem[];
@@ -19,6 +22,15 @@ export type SiteFooterColumn = {
19
22
  export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
20
23
  /** Link columns, left to right. Internal hrefs use `next/link`, external ones a plain `<a>`. */
21
24
  columns?: readonly SiteFooterColumn[] | undefined;
25
+ /** The one nav landmark around the columns. Default "Footer". Keep it unlike the header's. */
26
+ navLabel?: string | undefined;
27
+ /** The column titles' heading level. Default 2. */
28
+ headingLevel?: HeadingLevel | undefined;
29
+ /**
30
+ * Adds the "haruhime tools" column (haruhimeToolsColumn): the other haruhime.moe tools and
31
+ * "All tools". `current` leaves this tool out; `position` places it (default 1).
32
+ */
33
+ tools?: HaruhimeToolsOptions | undefined;
22
34
  /** Rendered above the fine print, e.g. a "clear local data" control. */
23
35
  extra?: ReactNode;
24
36
  /** One line of small print, e.g. a trademark notice. */
@@ -38,8 +50,8 @@ export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
38
50
  };
39
51
  /**
40
52
  * @function SiteFooter
41
- * @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
53
+ * @param props {SiteFooterProps} columns, the tools column, extra slot, fine print, parent, GitHub and Discord
42
54
  * links and their names, and native footer props
43
55
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
44
56
  */
45
- export declare function SiteFooter({ columns, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, discordLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
57
+ export declare function SiteFooter({ columns: ownColumns, navLabel, headingLevel, tools, extra, finePrint, parentLink, parentHref, githubHref, githubLabel, discordHref, discordLabel, className, ...props }: SiteFooterProps): import("react").JSX.Element;
@@ -4,6 +4,7 @@ import { AutoLink } from "../basics/AutoLink.js";
4
4
  import { DiscordIcon } from "../icons/DiscordIcon.js";
5
5
  import { GitHubIcon } from "../icons/GitHubIcon.js";
6
6
  import { HaruhimeWordmarkLink } from "../icons/HaruhimeWordmarkLink.js";
7
+ import { haruhimeToolsColumn } from "./haruhimeTools.js";
7
8
  import { LinkNote } from "./LinkNote.js";
8
9
  import { linkItemKey } from "./links.js";
9
10
  // The GitHub icon link in the bottom row.
@@ -15,11 +16,16 @@ const DISCORD_LINK = "shrink-0 text-c1 transition-opacity hover:opacity-80";
15
16
  const GRID_COLUMNS = ["", "", "sm:grid-cols-2", "sm:grid-cols-3", "sm:grid-cols-4"];
16
17
  /**
17
18
  * @function SiteFooter
18
- * @param props {SiteFooterProps} columns, extra slot, fine print, parent, GitHub and Discord
19
+ * @param props {SiteFooterProps} columns, the tools column, extra slot, fine print, parent, GitHub and Discord
19
20
  * links and their names, and native footer props
20
21
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
21
22
  */
22
- export function SiteFooter({ columns = [], extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", discordHref, discordLabel = "Discord", className, ...props }) {
23
+ export function SiteFooter({ columns: ownColumns = [], navLabel = "Footer", headingLevel = 2, tools, extra, finePrint, parentLink = true, parentHref = "https://www.haruhime.moe", githubHref = "https://github.com/haruhimemoe", githubLabel = "haruhimemoe on GitHub", discordHref, discordLabel = "Discord", className, ...props }) {
24
+ const columns = [...ownColumns];
25
+ if (tools) {
26
+ const at = Math.max(0, Math.min(tools.position ?? 1, columns.length));
27
+ columns.splice(at, 0, haruhimeToolsColumn(tools));
28
+ }
23
29
  const fine = finePrint ? _jsx("p", { className: "text-c4 text-xs", children: finePrint }) : null;
24
30
  const github = githubHref ? (_jsx("a", { href: githubHref, "aria-label": githubLabel, className: ICON_LINK, children: _jsx(GitHubIcon, {}) })) : null;
25
31
  const discord = discordHref ? (_jsx("a", { href: discordHref, "aria-label": discordLabel, className: DISCORD_LINK, children: _jsx(DiscordIcon, {}) })) : null;
@@ -28,5 +34,7 @@ export function SiteFooter({ columns = [], extra, finePrint, parentLink = true,
28
34
  // With the wordmark, the fine print gets its own line and the row holds wordmark + icon.
29
35
  // Without it, the fine print shares the row with the icon.
30
36
  const rowStart = parentLink ? _jsx(HaruhimeWordmarkLink, { href: parentHref }) : fine;
31
- 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 })), _jsx(LinkNote, { note: item.note })] }, linkItemKey(item)))) })] }, 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] }) }));
37
+ const Heading = `h${headingLevel}`;
38
+ const columnId = (title) => `footer-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
39
+ 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("nav", { "aria-label": navLabel, className: cx("grid gap-8", GRID_COLUMNS[Math.min(columns.length, 4)]), children: columns.map((column) => (_jsxs("section", { "aria-labelledby": columnId(column.title), children: [_jsx(Heading, { id: columnId(column.title), 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 })), _jsx(LinkNote, { note: item.note })] }, linkItemKey(item)))) })] }, 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] }) }));
32
40
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @file src/components/shell/haruhimeTools.ts
3
+ * @desc The haruhime.moe tools as data (HARUHIME_TOOLS) and the footer column that links them
4
+ * from each tool (haruhimeToolsColumn): every other tool plus "All tools" on the parent
5
+ * site. Sibling subdomains are separate sites to search engines, so each tool links the
6
+ * others. Server-safe: no directive, no hooks.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ import type { SiteLinkItem } from "./links.js";
12
+ /** A live haruhime.moe tool's id: its subdomain. */
13
+ export type HaruhimeToolId = "packs" | "pools" | "bb";
14
+ /** One live haruhime.moe tool: its name, home page and what it is in a few words. */
15
+ export type HaruhimeTool = {
16
+ id: HaruhimeToolId;
17
+ name: string;
18
+ href: string;
19
+ /** A few words after the name in the footer, like "mappool downloads". */
20
+ blurb: string;
21
+ };
22
+ /** The live haruhime.moe tools in launch order. A tool joins this list once it's live. */
23
+ export declare const HARUHIME_TOOLS: readonly HaruhimeTool[];
24
+ /** haruhimeToolsColumn's (and SiteFooter's `tools` prop's) options. */
25
+ export type HaruhimeToolsOptions = {
26
+ /** The tool the footer sits on. It's left out of the list (its own column links it). */
27
+ current?: HaruhimeToolId | undefined;
28
+ /** The column's title (and nav landmark name). Default "haruhime tools". */
29
+ title?: string | undefined;
30
+ /** The last entry, linking the parent site. Default "All tools"; false leaves it out. */
31
+ allLabel?: string | false | undefined;
32
+ /** Where "All tools" links. Default "https://www.haruhime.moe". */
33
+ allHref?: string | undefined;
34
+ /** Where SiteFooter puts the column among `columns` (0 is first). Default 1, after the first. */
35
+ position?: number | undefined;
36
+ };
37
+ /**
38
+ * @function haruhimeToolsColumn
39
+ * @param options {HaruhimeToolsOptions} the current tool, title and "All tools" link
40
+ * @returns {{ title: string; items: SiteLinkItem[] }} a footer column: each other tool as
41
+ * "name: blurb", then "All tools" on the parent site
42
+ */
43
+ export declare function haruhimeToolsColumn(options?: HaruhimeToolsOptions): {
44
+ title: string;
45
+ items: SiteLinkItem[];
46
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file src/components/shell/haruhimeTools.ts
3
+ * @desc The haruhime.moe tools as data (HARUHIME_TOOLS) and the footer column that links them
4
+ * from each tool (haruhimeToolsColumn): every other tool plus "All tools" on the parent
5
+ * site. Sibling subdomains are separate sites to search engines, so each tool links the
6
+ * others. Server-safe: no directive, no hooks.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Mon Sep 28, 2026
10
+ */
11
+ /** The live haruhime.moe tools in launch order. A tool joins this list once it's live. */
12
+ export const HARUHIME_TOOLS = [
13
+ { id: "packs", name: "packs", href: "https://packs.haruhime.moe", blurb: "mappool downloads" },
14
+ { id: "pools", name: "pools", href: "https://pools.haruhime.moe", blurb: "mappool builder" },
15
+ { id: "bb", name: "bb", href: "https://bb.haruhime.moe", blurb: "osu! BBCode editor" },
16
+ ];
17
+ /**
18
+ * @function haruhimeToolsColumn
19
+ * @param options {HaruhimeToolsOptions} the current tool, title and "All tools" link
20
+ * @returns {{ title: string; items: SiteLinkItem[] }} a footer column: each other tool as
21
+ * "name: blurb", then "All tools" on the parent site
22
+ */
23
+ export function haruhimeToolsColumn(options = {}) {
24
+ const { current, title = "haruhime tools", allLabel = "All tools" } = options;
25
+ const items = HARUHIME_TOOLS.filter((tool) => tool.id !== current).map((tool) => ({ href: tool.href, label: `${tool.name}: ${tool.blurb}` }));
26
+ if (allLabel !== false) {
27
+ items.push({ href: options.allHref ?? "https://www.haruhime.moe", label: allLabel });
28
+ }
29
+ return { title, items };
30
+ }
@@ -5,9 +5,9 @@
5
5
  * Server-safe. Fill it with THead, TBody, Th and Td.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sat Oct 3, 2026
9
9
  */
10
- import type { ComponentProps, ReactNode } from "react";
10
+ import { type ComponentProps, type ReactNode } from "react";
11
11
  /** Every native `<table>` prop (including `ref`), plus a caption and the wrapper's classes. */
12
12
  export type TableProps = ComponentProps<"table"> & {
13
13
  /** Names the table for screen readers. Shown above it unless `hideCaption`. */
@@ -16,6 +16,8 @@ export type TableProps = ComponentProps<"table"> & {
16
16
  hideCaption?: boolean | undefined;
17
17
  /** Classes for the wrapper `<div>` that scrolls sideways. */
18
18
  wrapperClassName?: string | undefined;
19
+ /** The scroll wrapper's accessible name when there is no caption. Default "Table". */
20
+ scrollLabel?: string | undefined;
19
21
  };
20
22
  /**
21
23
  * @function Table
@@ -23,4 +25,4 @@ export type TableProps = ComponentProps<"table"> & {
23
25
  * wrapperClassName; `className` and `ref` go on the `<table>`
24
26
  * @returns {JSX.Element} an `overflow-x-auto` `<div>` around the `<table>` and its caption
25
27
  */
26
- export declare function Table({ caption, hideCaption, wrapperClassName, className, children, ...props }: TableProps): import("react").JSX.Element;
28
+ export declare function Table({ caption, hideCaption, wrapperClassName, scrollLabel, className, children, ...props }: TableProps): import("react").JSX.Element;
@@ -1,4 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * @file src/components/tables/Table.tsx
4
+ * @desc A data table on the apps' look: full width, small left-aligned text, in a wrapper that
5
+ * scrolls sideways on phones, with an optional caption (visible, or for screen readers only).
6
+ * Server-safe. Fill it with THead, TBody, Th and Td.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import { useId } from "react";
2
12
  import { cx } from "../../utils/cx.js";
3
13
  import { FIELD_LABEL } from "../forms/fieldStyles.js";
4
14
  /**
@@ -7,6 +17,9 @@ import { FIELD_LABEL } from "../forms/fieldStyles.js";
7
17
  * wrapperClassName; `className` and `ref` go on the `<table>`
8
18
  * @returns {JSX.Element} an `overflow-x-auto` `<div>` around the `<table>` and its caption
9
19
  */
10
- export function Table({ caption, hideCaption = false, wrapperClassName, className, children, ...props }) {
11
- return (_jsx("div", { className: cx("overflow-x-auto", wrapperClassName), children: _jsxs("table", { className: cx("w-full text-left text-sm", className), ...props, children: [caption ? (_jsx("caption", { className: hideCaption ? "sr-only" : `mb-2 text-left ${FIELD_LABEL}`, children: caption })) : null, children] }) }));
20
+ export function Table({ caption, hideCaption = false, wrapperClassName, scrollLabel = "Table", className, children, ...props }) {
21
+ const captionId = useId();
22
+ return (_jsx("section", {
23
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region needs keyboard focus
24
+ tabIndex: 0, "aria-labelledby": caption ? captionId : undefined, "aria-label": caption ? undefined : scrollLabel, className: cx("overflow-x-auto", wrapperClassName), children: _jsxs("table", { className: cx("w-full text-left text-sm", className), ...props, children: [caption ? (_jsx("caption", { id: captionId, className: hideCaption ? "sr-only" : `mb-2 text-left ${FIELD_LABEL}`, children: caption })) : null, children] }) }));
12
25
  }
package/dist/index.d.ts CHANGED
@@ -51,6 +51,7 @@ export { type BeatmapStatKey, BeatmapStats, type BeatmapStatsProps, } from "./co
51
51
  export { ModBadge, type ModBadgeProps } from "./components/osu/ModBadge.js";
52
52
  export { StarRating, type StarRatingProps } from "./components/osu/StarRating.js";
53
53
  export { HeaderMenu, type HeaderMenuItem, type HeaderMenuProps, } from "./components/shell/HeaderMenu.js";
54
+ export { HARUHIME_TOOLS, type HaruhimeTool, type HaruhimeToolId, type HaruhimeToolsOptions, haruhimeToolsColumn, } from "./components/shell/haruhimeTools.js";
54
55
  export { type LinkTabItem, LinkTabs, type LinkTabsProps } from "./components/shell/LinkTabs.js";
55
56
  export type { SiteLinkItem } from "./components/shell/links.js";
56
57
  export { NavLinks, type NavLinksProps, type SiteNavAlign } from "./components/shell/NavLinks.js";
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ export { ModBadge } from "./components/osu/ModBadge.js";
57
57
  export { StarRating } from "./components/osu/StarRating.js";
58
58
  // Shell
59
59
  export { HeaderMenu, } from "./components/shell/HeaderMenu.js";
60
+ export { HARUHIME_TOOLS, haruhimeToolsColumn, } from "./components/shell/haruhimeTools.js";
60
61
  export { LinkTabs } from "./components/shell/LinkTabs.js";
61
62
  export { NavLinks } from "./components/shell/NavLinks.js";
62
63
  export { PageShell } from "./components/shell/PageShell.js";
package/dist/theme.css CHANGED
@@ -10,12 +10,12 @@
10
10
  * @import "@haruhimemoe/ui/theme.css";
11
11
  *
12
12
  * Set --hue on :root to recolor everything (default 333, pink). Some hues need --h2-l
13
- * (h2 lightness, default 45%) or --h1-l (h1 lightness, default 70%) moved to keep text at
14
- * 4.5:1; the README lists values. Load Nunito with next/font as the --font-nunito variable;
13
+ * (h2 lightness, default 45%) or --h1-l (h1 lightness, default 76%, which clears 4.5:1 on
14
+ * b5 at every hue) moved to keep text at 4.5:1; the README lists values. Load Nunito with next/font as the --font-nunito variable;
15
15
  * without it the sans stack falls back to the system font.
16
16
  * @author David @dvhsh (https://dvh.sh)
17
17
  * @created Wed Sep 23, 2026
18
- * @modified Wed Sep 23, 2026
18
+ * @modified Sat Oct 3, 2026
19
19
  */
20
20
 
21
21
  /* This file ships as dist/theme.css, so './' is the package's compiled components. */
@@ -39,7 +39,7 @@
39
39
  --color-c3: hsl(var(--hue) 40% 80%);
40
40
  --color-c4: hsl(var(--hue) 40% 70%);
41
41
 
42
- --color-h1: hsl(var(--hue) 100% var(--h1-l, 70%));
42
+ --color-h1: hsl(var(--hue) 100% var(--h1-l, 76%));
43
43
  --color-h2: hsl(var(--hue) 50% var(--h2-l, 45%));
44
44
 
45
45
  --font-sans: var(--font-nunito), ui-sans-serif, system-ui, sans-serif;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haruhimemoe/ui",
3
- "version": "0.5.1",
3
+ "version": "0.7.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 and confirms, filter controls, tables, osu! beatmap display pieces and the site header and footer.",
5
5
  "keywords": [
6
6
  "osu",