@haruhimemoe/ui 0.7.0 → 0.8.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 (46) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +114 -11
  3. package/dist/components/basics/Disclosure.d.ts +1 -1
  4. package/dist/components/basics/Disclosure.js +2 -2
  5. package/dist/components/forms/Checkbox.d.ts +1 -1
  6. package/dist/components/forms/Checkbox.js +3 -1
  7. package/dist/components/forms/RadioGroup.js +3 -1
  8. package/dist/components/palette/CommandPalette.d.ts +21 -0
  9. package/dist/components/palette/CommandPalette.js +315 -0
  10. package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
  11. package/dist/components/palette/CommandPaletteButton.js +26 -0
  12. package/dist/components/palette/PaletteFooter.d.ts +21 -0
  13. package/dist/components/palette/PaletteFooter.js +10 -0
  14. package/dist/components/palette/PaletteInput.d.ts +30 -0
  15. package/dist/components/palette/PaletteInput.js +23 -0
  16. package/dist/components/palette/PaletteList.d.ts +29 -0
  17. package/dist/components/palette/PaletteList.js +44 -0
  18. package/dist/components/palette/PaletteRow.d.ts +30 -0
  19. package/dist/components/palette/PaletteRow.js +33 -0
  20. package/dist/components/palette/calc.d.ts +28 -0
  21. package/dist/components/palette/calc.js +226 -0
  22. package/dist/components/palette/fuzzy.d.ts +51 -0
  23. package/dist/components/palette/fuzzy.js +138 -0
  24. package/dist/components/palette/hotkeys.d.ts +57 -0
  25. package/dist/components/palette/hotkeys.js +111 -0
  26. package/dist/components/palette/paletteEvents.d.ts +22 -0
  27. package/dist/components/palette/paletteEvents.js +22 -0
  28. package/dist/components/palette/platform.d.ts +13 -0
  29. package/dist/components/palette/platform.js +21 -0
  30. package/dist/components/palette/recents.d.ts +48 -0
  31. package/dist/components/palette/recents.js +85 -0
  32. package/dist/components/palette/rows.d.ts +63 -0
  33. package/dist/components/palette/rows.js +136 -0
  34. package/dist/components/palette/siteCommands.d.ts +38 -0
  35. package/dist/components/palette/siteCommands.js +152 -0
  36. package/dist/components/palette/store.d.ts +94 -0
  37. package/dist/components/palette/store.js +135 -0
  38. package/dist/components/palette/types.d.ts +102 -0
  39. package/dist/components/palette/types.js +11 -0
  40. package/dist/components/palette/useProviderSearch.d.ts +28 -0
  41. package/dist/components/palette/useProviderSearch.js +68 -0
  42. package/dist/components/shell/HeaderMenu.d.ts +1 -1
  43. package/dist/components/shell/HeaderMenu.js +2 -2
  44. package/dist/index.d.ts +8 -1
  45. package/dist/index.js +8 -1
  46. package/package.json +8 -3
package/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-10-03
10
+
11
+ ### Added
12
+
13
+ - `CommandPalette`: a mod+k command palette in a native `<dialog>`. Fuzzy search over commands with group headings and marked matches, nested pages (Backspace or Escape go back), async providers that search as you type (debounced, aborted when superseded), argument prompts (text, number, choice) before a command runs, a Recent group from localStorage, and a calculator row (`2*21` → `= 42`, Enter copies). Command shortcuts (`mod+shift+c`, chords like `g p`) work while the palette is closed. `openCommandPalette(page?)` opens it from anywhere; `CommandPaletteButton` is the header button with the platform hint. Built as a combobox over a listbox, with an `h1` edge on the active row, a focus cue on the input row, status errors and a polite result count.
14
+ - `siteCommands(options)`: the defaults every tool gets: Go to each nav page, Open each other haruhime tool, Copy page URL, Go back, Scroll to top, Reload, Open on GitHub, Sign in / My account / Sign out, Keyboard shortcuts, Report a bug.
15
+ - `fuzzyScore` and `evaluate` / `formatResult` are public, so an app can rank its provider rows the same way and reuse the calculator.
16
+ - `playground/`: a Next.js app in the repo that renders the components from `src/` (`bun run play`), and `bun run play:axe`, which builds it and runs axe-core in Chromium over the palette's states. Not published.
17
+
18
+ ### Changed
19
+
20
+ - `Checkbox` and `RadioGroup` boxes are 24px (WCAG 2.2 target size; they were the browser's 13px), centered on the first line of their label. `Disclosure` and `HeaderMenu` buttons are at least 24px tall.
21
+ - The consumer check runs axe-core in headless Chromium over the fixture page with color contrast and target-size checks on, at 1280 and 390 wide, after `next build`. CI installs Chromium for it.
22
+
9
23
  ## [0.7.0] - 2026-10-03
10
24
 
11
25
  ### Changed
@@ -110,7 +124,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
110
124
  - `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`).
111
125
  - 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).
112
126
 
113
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.7.0...HEAD
127
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.8.0...HEAD
128
+ [0.8.0]: https://github.com/haruhimemoe/ui/compare/v0.7.0...v0.8.0
114
129
  [0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
115
130
  [0.6.0]: https://github.com/haruhimemoe/ui/compare/v0.5.1...v0.6.0
116
131
  [0.5.1]: https://github.com/haruhimemoe/ui/compare/v0.5.0...v0.5.1
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  # @haruhimemoe/ui
4
4
 
5
- React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
5
+ React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-web-style palette as a Tailwind 4 theme, plus buttons, links, badges, form fields and confirms, filter controls (toggle and choice chips, a two-thumb range slider, a filter panel), tables, osu! beatmap display pieces, a command palette (mod+k, with the defaults every tool shares) and the site header, footer, tabs, account menu and page frame. Most components are Server Components. The few that need the browser carry `"use client"` in their own files, so you import everything from one place.
6
6
 
7
7
  See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
8
8
 
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.
9
+ This README describes version 0.8.0. Anything marked "since 0.8.0" is not in 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
 
@@ -153,7 +153,7 @@ export default function Home() {
153
153
 
154
154
  Import every component from `@haruhimemoe/ui`, in Server and Client Components alike.
155
155
 
156
- - **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider` and `FilterPanel`, and since 0.4.0 `AsyncButton`, `InlineConfirm`, `Disclosure`, `ChoiceChips`, `RadioGroup`, `TypeToConfirm` and `HeaderMenu`, and since 0.5.0 `Tabs`, `VisibilitySelect` and `ReportDisclosure`. Each file starts with `"use client"`. They merge their classes with tailwind-merge in the browser, so a page that renders any of them loads tailwind-merge (about 9 KB gzipped), however its header renders.
156
+ - **Client components:** `CopyButton`, `Chip`, `ChipGroup`, `RangeSlider` and `FilterPanel`, and since 0.4.0 `AsyncButton`, `InlineConfirm`, `Disclosure`, `ChoiceChips`, `RadioGroup`, `TypeToConfirm` and `HeaderMenu`, and since 0.5.0 `Tabs`, `VisibilitySelect` and `ReportDisclosure`, and since 0.8.0 `CommandPalette` and `CommandPaletteButton`. Each file starts with `"use client"`. They merge their classes with tailwind-merge in the browser, so a page that renders any of them loads tailwind-merge (about 9 KB gzipped), however its header renders.
157
157
  - **`SiteHeader` and `NavLinks`** are Server Components with a small client part (since 0.2.0; in 0.1.0 `NavLinks` is a client component). When a nav link can be the current page (a path such as `/packs`), a client list reads the path to set `aria-current`. With only external or text-only links, the nav renders on the server alone and nothing in it hydrates. Relative hrefs (`#main`) skip the client list too, but they render `next/link`, which hydrates.
158
158
  - **Everything else is server-safe:** no state, no effects, no browser APIs.
159
159
 
@@ -255,7 +255,7 @@ Later sections keep their heading margin, which spaces them apart.
255
255
 
256
256
  #### `Disclosure` (client)
257
257
 
258
- Since 0.4.0. A button that shows and hides a panel below it, with `aria-expanded` and `aria-controls` and a ▾ / ▴ arrow. The closed panel stays in the page, hidden, so fields inside keep their values. Every native `<div>` prop except `children`, for the wrapper.
258
+ Since 0.4.0. A button (at least 24px tall) that shows and hides a panel below it, with `aria-expanded` and `aria-controls` and a ▾ / ▴ arrow. The closed panel stays in the page, hidden, so fields inside keep their values. Every native `<div>` prop except `children`, for the wrapper.
259
259
 
260
260
  | Prop | Type | Default | What it does |
261
261
  | --- | --- | --- | --- |
@@ -345,11 +345,11 @@ Every native `<select>` prop, plus the field props. Pass `<option>` elements as
345
345
 
346
346
  #### `Checkbox`
347
347
 
348
- Every native `<input>` prop except `type` (`checked`, `defaultChecked`, `onChange`, `name`, `disabled`...), plus the field props. The label is bold `text-c1` and the hint follows it inline after a dot. Clicking anywhere on the row toggles it. The label alone is the accessible name; the hint is the description. An `aria-labelledby` you pass is added after the label (since 0.4.0; 0.3.0 dropped it).
348
+ Every native `<input>` prop except `type` (`checked`, `defaultChecked`, `onChange`, `name`, `disabled`...), plus the field props. The box is 24px (since 0.8.0; it was the browser's 13px), the label is bold `text-c1` and the hint follows it inline after a dot. Clicking anywhere on the row toggles it. The label alone is the accessible name; the hint is the description. An `aria-labelledby` you pass is added after the label (since 0.4.0; 0.3.0 dropped it).
349
349
 
350
350
  #### `RadioGroup` (client)
351
351
 
352
- Since 0.4.0. A native radio group on the `Checkbox` look: a `<fieldset>` named by its `<legend>`, one radio per option with a bold label and an inline hint, then the group's hint and error. Each option's label is its accessible name and its hint its description. Arrow keys move and pick, as native radios do. Every native `<fieldset>` prop except `onChange`, `children` and `defaultValue`; `disabled` turns off every radio. Types: `RadioOption`.
352
+ Since 0.4.0. A native radio group on the `Checkbox` look: a `<fieldset>` named by its `<legend>`, one 24px radio per option with a bold label and an inline hint, then the group's hint and error. Each option's label is its accessible name and its hint its description. Arrow keys move and pick, as native radios do. Every native `<fieldset>` prop except `onChange`, `children` and `defaultValue`; `disabled` turns off every radio. Types: `RadioOption`.
353
353
 
354
354
  | Prop | Type | Default | What it does |
355
355
  | --- | --- | --- | --- |
@@ -788,7 +788,7 @@ Since 0.4.0. A row of link tabs (Pools / Maps, All / Hidden): a named `<nav>` wi
788
788
 
789
789
  #### `HeaderMenu` (client)
790
790
 
791
- Since 0.4.0. The header's account menu: a button (an avatar and a name, say) that shows a small panel of links and extra controls such as a sign-out button. It is a disclosure, not an ARIA menu: the button has `aria-expanded` and `aria-controls`, and Tab moves through the panel. Escape closes it and puts focus back on the button; a click outside it, a click on one of its links, or focus leaving it closes it too. Every native `<div>` prop except `children`, for the wrapper. Type: `HeaderMenuItem`.
791
+ Since 0.4.0. The header's account menu: a button (an avatar and a name, say; at least 24px tall) that shows a small panel of links and extra controls such as a sign-out button. It is a disclosure, not an ARIA menu: the button has `aria-expanded` and `aria-controls`, and Tab moves through the panel. Escape closes it and puts focus back on the button; a click outside it, a click on one of its links, or focus leaving it closes it too. Every native `<div>` prop except `children`, for the wrapper. Type: `HeaderMenuItem`.
792
792
 
793
793
  | Prop | Type | Default | What it does |
794
794
  | --- | --- | --- | --- |
@@ -845,6 +845,108 @@ A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: N
845
845
  | --- | --- | --- | --- |
846
846
  | `mod` | `string` | required | The mod or slot label. |
847
847
 
848
+ ### Palette (client)
849
+
850
+ Since 0.8.0. A command palette: press Ctrl K (⌘K on a Mac) anywhere on the page and a dialog opens with a search box over everything the app can do. It is one component for every haruhime tool: `CommandPalette` is the engine, `siteCommands` the defaults every site shares, and each app plugs in its own commands, pages and search providers through the same `Command` and `Provider` types. No new dependency.
851
+
852
+ Mount it once, from a client file, since commands carry functions. The button goes in `SiteHeader`'s `actions`:
853
+
854
+ ```tsx
855
+ // src/components/Palette.tsx
856
+ "use client";
857
+
858
+ import { type Command, CommandPalette, siteCommands } from "@haruhimemoe/ui";
859
+ import { HEADER_LINKS } from "@/constants/nav";
860
+
861
+ const COMMANDS: Command[] = [
862
+ ...siteCommands({ pages: HEADER_LINKS, tools: "packs", repo: "https://github.com/haruhimemoe/packs.haruhime.moe" }),
863
+ { id: "pack.new", title: "New pack", group: "Packs", shortcut: "g n", run: (ctx) => ctx.navigate("/new") },
864
+ ];
865
+
866
+ export function Palette() {
867
+ return <CommandPalette storageKey="packs" commands={COMMANDS} />;
868
+ }
869
+ ```
870
+
871
+ ```tsx
872
+ // app/layout.tsx
873
+ <SiteHeader brand={...} links={HEADER_LINKS} actions={<CommandPaletteButton>Search</CommandPaletteButton>} />
874
+ <Palette />
875
+ ```
876
+
877
+ #### `CommandPalette`
878
+
879
+ The dialog. Renders nothing until opened, then a native `<dialog>` (modal, backdrop, scroll locked) with a combobox over a listbox. Mount one per app.
880
+
881
+ | Prop | Type | Default | What it does |
882
+ | --- | --- | --- | --- |
883
+ | `commands` | `readonly Command[]` | required | The root rows. Read on every open, so a command's `when` and titles can change. |
884
+ | `providers` | `readonly Provider[]` | none | Searched at the root as you type, once the query reaches each one's `minLength`. |
885
+ | `storageKey` | `string` | `"default"` | Namespaces the recents in `localStorage` (`haruhime:palette:<key>`). |
886
+ | `hotkey` | `string` | `"mod+k"` | The toggle, in shortcut syntax. `mod` is ⌘ on a Mac and Ctrl elsewhere. |
887
+ | `placeholder` | `string` | `"Search commands…"` | The root input's placeholder. |
888
+ | `label` | `string` | `"Command palette"` | The dialog's and the input's accessible name. |
889
+ | `calculator` | `boolean` | `true` | A `= 42` row for a query that computes (`2*21`), first in the list; Enter copies the result. |
890
+ | `recents` | `boolean` | `true` | A Recent group of the last five commands run, and a ranking boost by how often each ran. |
891
+ | `className` | `string` | none | Classes for the panel. |
892
+
893
+ Keys: ↑ ↓ move (wrapping), Home and End jump, Enter runs the active row, Escape goes back a level and then closes, Backspace on an empty input goes back a level, Tab stays put (focus never leaves the input), and the hotkey (a combo, not a chord) toggles the palette from anywhere, a field included, without typing into it. A click on the backdrop closes it, and so does the browser (a back gesture on Android). Focus returns to whatever had it. The footer reads the result count, or the copy outcome until the query changes.
894
+
895
+ Typing filters the rows with a fuzzy match: a letter or digit must start a word or follow the previous match ("cpu" finds "Copy page URL", "go" doesn't find "Sign out"), except in scripts without case or spaces (kanji, kana), which match anywhere; the title weighs most, then `keywords`, `subtitle` and `group`. Matched letters are marked. With an empty query every command is listed under its group, in order, after the Recent group.
896
+
897
+ #### `Command`
898
+
899
+ | Field | Type | What it does |
900
+ | --- | --- | --- |
901
+ | `id` | `string` | Unique in the app. Recents are stored by it, so keep it stable when a title changes. |
902
+ | `title` | `string` | The row. |
903
+ | `subtitle?` | `string` | A smaller second line. |
904
+ | `icon?` | `ReactNode` | A 20px slot before the title. |
905
+ | `keywords?` | `string[]` | Matched at a lower weight than the title. |
906
+ | `group?` | `string` | The heading the row sits under. Default `"Commands"`. |
907
+ | `shortcut?` | `string` | Shown as keys on the row, and active while the palette is mounted and closed: a combo (`"mod+shift+c"`, `"?"`) or a chord of two bare keys (`"g p"`, within 800 ms). Never fires from a field. |
908
+ | `when?` | `(ctx) => boolean` | Hidden when false. Read on every render of the list. |
909
+ | `run?` | `(ctx, args) => void \| Promise<void>` | What it does. A rejection is logged; the palette stays usable. |
910
+ | `page?` | `Page` | Instead of `run`: Enter pushes this page (its `commands` and `providers`), with the page's title as a crumb. |
911
+ | `args?` | `ArgSpec[]` | Prompts collected before `run`, one at a time: `{ name, label, type: "text" \| "number" \| "choice", choices?, validate? }`. A `choice` lists its `choices` as rows (fuzzy-filtered); `text` and `number` take the input on Enter; `validate` returns a message to block it. `run` gets them as `args[name]`. |
912
+ | `closeOnRun?` | `boolean` | Default `true`. |
913
+
914
+ `PaletteContext` (the `ctx` a command runs with): `navigate(href)` (`router.push`), `close()`, `push(page)` (opens the palette first when closed), `copy(text)` (clipboard, announcing "Copied" or the failure), `pathname`, and `commands` (every root command, so a page can list them).
915
+
916
+ #### `Provider`
917
+
918
+ `{ id, group?, minLength? = 2, debounceMs? = 200, search(query, signal) }`. `search` returns `Command[]` for the query and must honor the `AbortSignal`: a newer query aborts the older search, and a late result is dropped. Its rows sit under `group` (default "Results") after the static matches; while it runs the list is `aria-busy`, says "Searching…" and keeps the last rows in place, so nothing flashes per keystroke; an error says "Couldn't search, try again". `fuzzyScore(query, text)` is exported so a provider can rank its rows the way the palette does.
919
+
920
+ #### `openCommandPalette(page?)`
921
+
922
+ Opens the mounted palette from anywhere (a button, a tour), onto `page` when given. It dispatches a `window` event, so it works from a Server Component's client child without a ref.
923
+
924
+ #### `CommandPaletteButton` (client)
925
+
926
+ A ghost `Button` with a magnifier, your `children` beside it and the hotkey hint (`Ctrl K`, or `⌘K` once a Mac is detected after mount; decorative, so it isn't part of the name). With `children`, that text is the button's name; without, `label` is (default "Open command palette"). Every `Button` prop except `onClick`.
927
+
928
+ #### `siteCommands(options)`
929
+
930
+ The defaults every tool gets, in order: Navigate, Page, Account, Help. Pass only what the app has.
931
+
932
+ | Option | Type | What it builds |
933
+ | --- | --- | --- |
934
+ | `pages` | `SiteLinkItem[]` | "Go to <label>" per linked nav item (`site.go.<slug>`). |
935
+ | `tools` | `HaruhimeToolId \| false` | "Open <tool>" for every other tool in `HARUHIME_TOOLS` plus "Open haruhime.moe" (`site.tool.<id>`, `site.tool.home`). `false` leaves them out. |
936
+ | `repo` | `string` | "Open on GitHub" (`site.github`) and "Report a bug" (`site.report`, the repo's new-issue page). |
937
+ | `account` | `{ signedIn, signInHref, accountHref?, signOutHref? }` | "Sign in" (`site.sign-in`) while signed out; "My account" (`site.account`) and "Sign out" (`site.sign-out`, navigated, as next-kit's route expects) while signed in. |
938
+ | `include` | `("navigate" \| "page" \| "account" \| "help")[]` | Which groups. Default all. |
939
+
940
+ Always there: "Copy page URL" (`site.copy-url`, mod+shift+c), "Go back" (`site.back`), "Scroll to top" (`site.top`, instant under reduced motion), "Reload page" (`site.reload`) and "Keyboard shortcuts" (`site.shortcuts`, `?`), a page listing every command that has a shortcut, the app's included.
941
+
942
+ #### Calculator
943
+
944
+ `evaluate(expression)` and `formatResult(value)` are exported. The grammar: `+ - * / % ^`, unary minus, parentheses, `k` and `m` suffixes (`1.5k`), `pi` and `e`, and `sqrt`, `abs`, `round`, `floor`, `ceil`, `min` and `max`. Anything else, and a result that isn't finite, is `null`. In the palette a bare number or word is a search, not a sum.
945
+
946
+ #### Accessibility
947
+
948
+ The input is a `combobox` over a `listbox`; the active row is named by `aria-activedescendant`, so focus never leaves the input. The active row shows an `h1` left edge as well as a tint, the input row's bottom border turns `h1` while it has focus, hint rows ("Searching…", "No matching commands") are disabled options, an argument's error is an always-mounted `role="status"`, group headings aren't uppercase, rows are 36px, and the footer's `<output>` reads the result count and the copy outcome. `bun run play:axe` runs axe-core in Chromium over the open palette's states with contrast on.
949
+
848
950
  ### Tables
849
951
 
850
952
  Since 0.4.0. `Table`, `THead`, `TBody`, `Th` and `Td` give a data table the apps' look: full width, small left-aligned text, muted capitals in the head and a rule above each body row. They are Server Components, and each takes its element's native props, `className` (merged last) and `ref`. Use plain `<tr>` for rows.
@@ -889,21 +991,22 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
889
991
  The target is WCAG 2.2 AA. House rules, which every component follows and your own code around them should too:
890
992
 
891
993
  - **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).
994
+ - **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), checkboxes and radios 24px and the `Disclosure` and `HeaderMenu` buttons at least 24px tall (since 0.8.0).
893
995
  - **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
996
  - **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
997
  - **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
998
  - **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.
999
+ - **The palette is a combobox.** `CommandPalette` keeps focus on its input and names the active row with `aria-activedescendant`; the row shows its state with an `h1` edge, the input row shows focus, and `bun run play:axe` checks its open states in a real browser (since 0.8.0).
897
1000
  - **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
1001
  - **Scrollable regions take focus.** `Table`'s wrapper is a focusable named section. Do the same for a `pre` inside `Prose`.
899
1002
  - **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
1003
  - **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
1004
  - **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.
1005
+ - **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). The consumer check then renders every component in a real Next.js app and runs axe in headless Chromium with contrast and target-size checks on, at a desktop and a phone width; the sites run the same pass over their pages. Interactive ones also have keyboard tests.
903
1006
 
904
1007
  ## Compatibility
905
1008
 
906
- | | Supported |
1009
+ | Requirement | Supported |
907
1010
  | --- | --- |
908
1011
  | Next.js | 16 (app router). Components use `next/link` and `next/navigation`. |
909
1012
  | React | 19 |
@@ -914,7 +1017,7 @@ The target is WCAG 2.2 AA. House rules, which every component follows and your o
914
1017
 
915
1018
  ## Changelog and contributing
916
1019
 
917
- See [CHANGELOG.md](./CHANGELOG.md) for what changed in each version and [CONTRIBUTING.md](./CONTRIBUTING.md) to work on the package. Report security issues as described in [SECURITY.md](./SECURITY.md). Bring questions and feedback to the haruhime.moe [Discord server](https://discord.gg/bKy9kjMV4y).
1020
+ See [CHANGELOG.md](./CHANGELOG.md) for what changed in each version and [CONTRIBUTING.md](./CONTRIBUTING.md) to work on the package. `bun run play` serves `playground/`, a small Next.js app in the repo that renders the components straight from `src/`, for trying a change by hand. Report security issues as described in [SECURITY.md](./SECURITY.md). Bring questions and feedback to the haruhime.moe [Discord server](https://discord.gg/bKy9kjMV4y).
918
1021
 
919
1022
  ## License
920
1023
 
@@ -5,7 +5,7 @@
5
5
  * so its form fields keep their values. Uncontrolled by default, or controlled with `open`.
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, type ReactNode } from "react";
11
11
  /** Every native `<div>` prop for the wrapper, plus the button's text and the panel. */
@@ -5,7 +5,7 @@
5
5
  * so its form fields keep their values. Uncontrolled by default, or controlled with `open`.
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
  "use client";
11
11
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -26,5 +26,5 @@ export function Disclosure({ summary, children, defaultOpen = false, open, onOpe
26
26
  setInner(!isOpen);
27
27
  onOpenChange?.(!isOpen);
28
28
  };
29
- return (_jsxs("div", { className: cx("flex flex-col gap-2", className), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isOpen, "aria-controls": panelId, onClick: toggle, className: cx("inline-flex items-center gap-1 self-start font-bold text-c2 text-sm transition-colors hover:text-c1", buttonClassName), children: [summary, _jsx("span", { "aria-hidden": "true", children: isOpen ? "▴" : "▾" })] }), _jsx("div", { id: panelId, hidden: !isOpen, className: panelClassName, children: children })] }));
29
+ return (_jsxs("div", { className: cx("flex flex-col gap-2", className), ...props, children: [_jsxs("button", { type: "button", "aria-expanded": isOpen, "aria-controls": panelId, onClick: toggle, className: cx("inline-flex min-h-6 items-center gap-1 self-start font-bold text-c2 text-sm transition-colors hover:text-c1", buttonClassName), children: [summary, _jsx("span", { "aria-hidden": "true", children: isOpen ? "▴" : "▾" })] }), _jsx("div", { id: panelId, hidden: !isOpen, className: panelClassName, children: children })] }));
30
30
  }
@@ -6,7 +6,7 @@
6
6
  * hint and error.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Wed Sep 23, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sat Oct 3, 2026
10
10
  */
11
11
  import type { ComponentProps } from "react";
12
12
  import { type FieldProps } from "./FieldFrame.js";
@@ -9,5 +9,7 @@ import { FieldError, fieldControlProps, hintId } from "./FieldFrame.js";
9
9
  */
10
10
  export function Checkbox({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, "aria-labelledby": labelledBy, ...props }) {
11
11
  const labelId = `${id}-label`;
12
- return (_jsxs("div", { className: cx("flex flex-col gap-1", wrapperClassName), children: [_jsxs("label", { htmlFor: id, className: "flex items-start gap-2 text-sm", children: [_jsx("input", { ...props, type: "checkbox", ...fieldControlProps({ id, hint, error, describedBy, invalid }), "aria-labelledby": labelledBy ? `${labelId} ${labelledBy}` : labelId, className: cx("mt-1 accent-h1", className) }), _jsxs("span", { children: [_jsx("span", { id: labelId, className: "font-bold text-c1", children: label }), hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(id), children: hint })] })) : null] })] }), _jsx(FieldError, { id: id, error: error })] }));
12
+ return (_jsxs("div", { className: cx("flex flex-col gap-1", wrapperClassName), children: [_jsxs("label", { htmlFor: id, className: "flex items-start gap-2 text-sm", children: [_jsx("input", { ...props, type: "checkbox", ...fieldControlProps({ id, hint, error, describedBy, invalid }), "aria-labelledby": labelledBy ? `${labelId} ${labelledBy}` : labelId,
13
+ // 24px, the WCAG 2.2 target size; -mt-0.5 centers it on the first text-sm line.
14
+ className: cx("-mt-0.5 size-6 shrink-0 accent-h1", className) }), _jsxs("span", { children: [_jsx("span", { id: labelId, className: "font-bold text-c1", children: label }), hint ? (_jsxs("span", { className: "text-c3", children: [" · ", _jsx("span", { id: hintId(id), children: hint })] })) : null] })] }), _jsx(FieldError, { id: id, error: error })] }));
13
15
  }
@@ -27,6 +27,8 @@ 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-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),
31
+ // 24px, the WCAG 2.2 target size; -mt-0.5 centers it on the first text-sm line.
32
+ className: "-mt-0.5 size-6 shrink-0 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
33
  }), hint ? (_jsx("div", { id: hintId(id), className: "text-c4 text-xs", children: hint })) : null, _jsx(FieldError, { id: id, error: error })] }));
32
34
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @file src/components/palette/CommandPalette.tsx
3
+ * @desc The command palette: a native <dialog> opened by mod+k, `openCommandPalette()` or a
4
+ * command shortcut, with a combobox over the rows the store and row builder produce.
5
+ * Enter runs a command, pushes its page, or starts collecting its args; Escape and
6
+ * Backspace on an empty input go back a level, then close. Providers search as you type.
7
+ * Recents are read on open and written on every run. The calculator row copies its result.
8
+ * Mount once, inside a client component, since commands carry functions.
9
+ * @author David @dvhsh (https://dvh.sh)
10
+ * @created Sat Oct 3, 2026
11
+ * @modified Sat Oct 3, 2026
12
+ */
13
+ import type { CommandPaletteProps } from "./types.js";
14
+ export type { CommandPaletteProps } from "./types.js";
15
+ /**
16
+ * @function CommandPalette
17
+ * @param props {CommandPaletteProps} commands, root providers, storage key, hotkey, placeholder,
18
+ * label, calculator and recents switches, panel classes
19
+ * @returns {JSX.Element} the dialog, empty until opened
20
+ */
21
+ export declare function CommandPalette({ commands, providers, storageKey, hotkey, placeholder, label, calculator, recents: recentsOn, className, }: CommandPaletteProps): import("react").JSX.Element;