@haruhimemoe/ui 0.6.0 → 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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ 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
+
9
21
  ## [0.6.0] - 2026-09-28
10
22
 
11
23
  ### Added
@@ -98,7 +110,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
98
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`).
99
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).
100
112
 
101
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.6.0...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
102
115
  [0.6.0]: https://github.com/haruhimemoe/ui/compare/v0.5.1...v0.6.0
103
116
  [0.5.1]: https://github.com/haruhimemoe/ui/compare/v0.5.0...v0.5.1
104
117
  [0.5.0]: https://github.com/haruhimemoe/ui/compare/v0.4.0...v0.5.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,9 @@ 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). |
763
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. |
764
769
  | `extra` | `ReactNode` | none | Shown above the fine print, e.g. a "clear local data" button. |
765
770
  | `finePrint` | `ReactNode` | none | One line of small print, in a `<p>`. |
@@ -813,7 +818,7 @@ Since 0.4.0. Display pieces for beatmaps and mod pools. They take plain values (
813
818
 
814
819
  #### `StarRating`
815
820
 
816
- 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.
817
822
 
818
823
  | Prop | Type | Default | What it does |
819
824
  | --- | --- | --- | --- |
@@ -823,7 +828,7 @@ A star-rating pill colored on osu!'s difficulty spectrum: "★ 5.23" on the rati
823
828
 
824
829
  #### `BeatmapStats`
825
830
 
826
- 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`.
827
832
 
828
833
  | Prop | Type | Default | What it does |
829
834
  | --- | --- | --- | --- |
@@ -865,7 +870,7 @@ Since 0.4.0. `Table`, `THead`, `TBody`, `Th` and `Td` give a data table the apps
865
870
 
866
871
  | Component | Extra props | What it renders |
867
872
  | --- | --- | --- |
868
- | `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. |
869
874
  | `THead` | none | A `<thead>` in `c3`, `text-xs`, uppercase. |
870
875
  | `TBody` | none | A `<tbody>` whose rows get a `b4` top border. Add `[&>tr]:align-top` for rows of mixed height. |
871
876
  | `Th` | `numeric?: boolean` | A `<th>` with `scope="col"` by default. With `scope="row"` it is a row's heading, in bold `c1`. |
@@ -881,18 +886,20 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
881
886
 
882
887
  ## Accessibility
883
888
 
884
- - 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).
885
- - 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.
886
- - In forced colors mode, a pressed `Chip` takes the system highlight colors, so on and off still look different.
887
- - Form fields link their label, hint and error. An error sets `aria-invalid` and is announced.
888
- - `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.
889
- - `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.
890
- - `CopyButton` announces "Copied." (or the failure) through an `<output>`, on every press.
891
- - `Pagination` moves focus to its "Page X of Y" text when the link you pressed goes away on the first or last page.
892
- - `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.
893
- - `SiteHeader` marks the current page with `aria-current`. `PageShell` starts with a skip link to `<main>`.
894
- - `DiscordIcon` and `GitHubIcon` are hidden from screen readers by default: give the link around each one an `aria-label`, as `SiteFooter` does.
895
- - 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.
896
903
 
897
904
  ## Compatibility
898
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,18 +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
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
4
  * a small note, like "soon"), an optional "haruhime tools" column linking the other tools,
5
5
  * an optional extra slot, one line of fine print, the
6
6
  * haruhime.moe wordmark linking the parent site, a GitHub icon link, and an optional
7
7
  * Discord icon link beside it (white, as Discord's brand guidelines ask).
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
+ import type { HeadingLevel } from "../basics/cardStyles.js";
13
14
  import { type HaruhimeToolsOptions } from "./haruhimeTools.js";
14
15
  import { type SiteLinkItem } from "./links.js";
15
- /** 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. */
16
17
  export type SiteFooterColumn = {
17
18
  title: string;
18
19
  items: readonly SiteLinkItem[];
@@ -21,6 +22,10 @@ export type SiteFooterColumn = {
21
22
  export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
22
23
  /** Link columns, left to right. Internal hrefs use `next/link`, external ones a plain `<a>`. */
23
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;
24
29
  /**
25
30
  * Adds the "haruhime tools" column (haruhimeToolsColumn): the other haruhime.moe tools and
26
31
  * "All tools". `current` leaves this tool out; `position` places it (default 1).
@@ -49,4 +54,4 @@ export type SiteFooterProps = Omit<ComponentProps<"footer">, "children"> & {
49
54
  * links and their names, and native footer props
50
55
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
51
56
  */
52
- export declare function SiteFooter({ columns: ownColumns, tools, 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;
@@ -20,7 +20,7 @@ const GRID_COLUMNS = ["", "", "sm:grid-cols-2", "sm:grid-cols-3", "sm:grid-cols-
20
20
  * links and their names, and native footer props
21
21
  * @returns {JSX.Element} the footer: columns on top, then extra, fine print and the brand row
22
22
  */
23
- export function SiteFooter({ columns: ownColumns = [], tools, 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
24
  const columns = [...ownColumns];
25
25
  if (tools) {
26
26
  const at = Math.max(0, Math.min(tools.position ?? 1, columns.length));
@@ -34,5 +34,7 @@ export function SiteFooter({ columns: ownColumns = [], tools, extra, finePrint,
34
34
  // With the wordmark, the fine print gets its own line and the row holds wordmark + icon.
35
35
  // Without it, the fine print shares the row with the icon.
36
36
  const rowStart = parentLink ? _jsx(HaruhimeWordmarkLink, { href: parentHref }) : fine;
37
- 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] }) }));
38
40
  }
@@ -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/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.6.0",
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",