@haruhimemoe/ui 0.7.0 → 0.9.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 (89) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.md +246 -11
  3. package/dist/components/basics/Disclosure.d.ts +1 -1
  4. package/dist/components/basics/Disclosure.js +2 -2
  5. package/dist/components/basics/Prose.d.ts +9 -6
  6. package/dist/components/basics/Prose.js +4 -4
  7. package/dist/components/forms/Checkbox.d.ts +1 -1
  8. package/dist/components/forms/Checkbox.js +3 -1
  9. package/dist/components/forms/RadioGroup.js +3 -1
  10. package/dist/components/mdx/Callout.d.ts +24 -0
  11. package/dist/components/mdx/Callout.js +22 -0
  12. package/dist/components/mdx/CodeBlock.d.ts +26 -0
  13. package/dist/components/mdx/CodeBlock.js +48 -0
  14. package/dist/components/mdx/CodeCopyButton.d.ts +21 -0
  15. package/dist/components/mdx/CodeCopyButton.js +34 -0
  16. package/dist/components/mdx/MdxBlockquote.d.ts +23 -0
  17. package/dist/components/mdx/MdxBlockquote.js +17 -0
  18. package/dist/components/mdx/MdxHeading.d.ts +26 -0
  19. package/dist/components/mdx/MdxHeading.js +23 -0
  20. package/dist/components/mdx/MdxLink.d.ts +29 -0
  21. package/dist/components/mdx/MdxLink.js +26 -0
  22. package/dist/components/mdx/MdxPre.d.ts +25 -0
  23. package/dist/components/mdx/MdxPre.js +39 -0
  24. package/dist/components/mdx/MdxTable.d.ts +20 -0
  25. package/dist/components/mdx/MdxTable.js +23 -0
  26. package/dist/components/mdx/highlighter.d.ts +37 -0
  27. package/dist/components/mdx/highlighter.js +66 -0
  28. package/dist/components/mdx/mdxComponents.d.ts +21 -0
  29. package/dist/components/mdx/mdxComponents.js +22 -0
  30. package/dist/components/mdx/parseCodeMeta.d.ts +21 -0
  31. package/dist/components/mdx/parseCodeMeta.js +40 -0
  32. package/dist/components/mdx/textOf.d.ts +17 -0
  33. package/dist/components/mdx/textOf.js +28 -0
  34. package/dist/components/palette/CommandPalette.d.ts +21 -0
  35. package/dist/components/palette/CommandPalette.js +315 -0
  36. package/dist/components/palette/CommandPaletteButton.d.ts +21 -0
  37. package/dist/components/palette/CommandPaletteButton.js +26 -0
  38. package/dist/components/palette/PaletteFooter.d.ts +21 -0
  39. package/dist/components/palette/PaletteFooter.js +10 -0
  40. package/dist/components/palette/PaletteInput.d.ts +30 -0
  41. package/dist/components/palette/PaletteInput.js +23 -0
  42. package/dist/components/palette/PaletteList.d.ts +29 -0
  43. package/dist/components/palette/PaletteList.js +44 -0
  44. package/dist/components/palette/PaletteRow.d.ts +30 -0
  45. package/dist/components/palette/PaletteRow.js +33 -0
  46. package/dist/components/palette/calc.d.ts +28 -0
  47. package/dist/components/palette/calc.js +226 -0
  48. package/dist/components/palette/fuzzy.d.ts +51 -0
  49. package/dist/components/palette/fuzzy.js +138 -0
  50. package/dist/components/palette/hotkeys.d.ts +57 -0
  51. package/dist/components/palette/hotkeys.js +111 -0
  52. package/dist/components/palette/paletteEvents.d.ts +22 -0
  53. package/dist/components/palette/paletteEvents.js +22 -0
  54. package/dist/components/palette/platform.d.ts +13 -0
  55. package/dist/components/palette/platform.js +21 -0
  56. package/dist/components/palette/recents.d.ts +48 -0
  57. package/dist/components/palette/recents.js +85 -0
  58. package/dist/components/palette/rows.d.ts +63 -0
  59. package/dist/components/palette/rows.js +136 -0
  60. package/dist/components/palette/siteCommands.d.ts +38 -0
  61. package/dist/components/palette/siteCommands.js +152 -0
  62. package/dist/components/palette/store.d.ts +94 -0
  63. package/dist/components/palette/store.js +135 -0
  64. package/dist/components/palette/types.d.ts +102 -0
  65. package/dist/components/palette/types.js +11 -0
  66. package/dist/components/palette/useProviderSearch.d.ts +28 -0
  67. package/dist/components/palette/useProviderSearch.js +68 -0
  68. package/dist/components/shell/HeaderMenu.d.ts +1 -1
  69. package/dist/components/shell/HeaderMenu.js +2 -2
  70. package/dist/index.d.ts +8 -1
  71. package/dist/index.js +8 -1
  72. package/dist/mdx.d.ts +17 -0
  73. package/dist/mdx.js +17 -0
  74. package/dist/remark/callouts.d.ts +20 -0
  75. package/dist/remark/callouts.js +58 -0
  76. package/dist/remark/codeMeta.d.ts +17 -0
  77. package/dist/remark/codeMeta.js +22 -0
  78. package/dist/remark/headingIds.d.ts +16 -0
  79. package/dist/remark/headingIds.js +28 -0
  80. package/dist/remark/index.d.ts +29 -0
  81. package/dist/remark/index.js +33 -0
  82. package/dist/remark/mdast.d.ts +42 -0
  83. package/dist/remark/mdast.js +37 -0
  84. package/dist/remark/slugify.d.ts +23 -0
  85. package/dist/remark/slugify.js +40 -0
  86. package/dist/shiki.d.ts +13 -0
  87. package/dist/shiki.js +38 -0
  88. package/dist/theme.css +18 -0
  89. package/package.json +34 -4
package/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] - 2026-10-03
10
+
11
+ ### Added
12
+
13
+ - `@haruhimemoe/ui/mdx`: `mdxComponents` (the `a`/`h2`/`h3`/`pre`/`table`/`blockquote` element overrides for `@next/mdx` and `react-markdown`), `CodeBlock` (a fenced code block, Shiki-highlighted when registered), `Callout` (a note/tip/warning aside, also reached through a GitHub-style `> [!NOTE]` blockquote), `parseCodeMeta` and `slugify`.
14
+ - `@haruhimemoe/ui/remark`: `remarkHaruhime` (default export, for Turbopack's module-name-only `remarkPlugins`) plus the named `remarkCodeMeta`, `remarkCallouts` and `remarkHeadingIds`, and `createSlugger`.
15
+ - `@haruhimemoe/ui/shiki`: an opt-in side-effect import (`shiki` is now an optional peer dependency) that registers Shiki's core highlighter for `CodeBlock`. An app that skips it, or doesn't install `shiki`, gets plain code blocks instead of a build failure.
16
+ - `theme.css`: `--shiki-foreground`, `--shiki-background` and the `--shiki-token-*` variables Shiki's CSS-variables theme reads, derived from `--hue` like the rest of the palette.
17
+
18
+ ### Changed
19
+
20
+ - `Prose` styles a `pre` at any depth, excluding `CodeBlock`'s own (`role="group"`), so a plain `pre` nested inside `li` or `blockquote` keeps its fence look while `CodeBlock` (as `mdxComponents`' `pre` override renders it) keeps its own. `Prose` also styles `blockquote`.
21
+ - `CodeBlock` accepts every native `<div>` prop (`id`, `data-*`, `aria-*`, …) except `code`, `lang`, `title` and `highlight`, spread onto its wrapper.
22
+
23
+ ## [0.8.0] - 2026-10-03
24
+
25
+ ### Added
26
+
27
+ - `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.
28
+ - `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.
29
+ - `fuzzyScore` and `evaluate` / `formatResult` are public, so an app can rank its provider rows the same way and reuse the calculator.
30
+ - `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.
31
+
32
+ ### Changed
33
+
34
+ - `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.
35
+ - 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.
36
+
9
37
  ## [0.7.0] - 2026-10-03
10
38
 
11
39
  ### Changed
@@ -110,7 +138,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
110
138
  - `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
139
  - 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
140
 
113
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.7.0...HEAD
141
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.9.0...HEAD
142
+ [0.9.0]: https://github.com/haruhimemoe/ui/compare/v0.8.0...v0.9.0
143
+ [0.8.0]: https://github.com/haruhimemoe/ui/compare/v0.7.0...v0.8.0
114
144
  [0.7.0]: https://github.com/haruhimemoe/ui/compare/v0.6.0...v0.7.0
115
145
  [0.6.0]: https://github.com/haruhimemoe/ui/compare/v0.5.1...v0.6.0
116
146
  [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.9.0. Anything marked "since 0.9.0" is not in 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,240 @@ 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
+ ### MDX (server)
849
+
850
+ Since 0.9.0. Three subpaths, so an app that never renders Markdown loads none of this: `@haruhimemoe/ui/mdx` (the React pieces: `mdxComponents`, `CodeBlock`, `Callout`), `@haruhimemoe/ui/remark` (plain functions, no React, for `@next/mdx` and `react-markdown`'s `remarkPlugins`), and `@haruhimemoe/ui/shiki` (opt-in code highlighting). The root `@haruhimemoe/ui` export is unchanged.
851
+
852
+ **With `@next/mdx`:**
853
+
854
+ ```ts
855
+ // next.config.ts
856
+ import createMDX from "@next/mdx";
857
+
858
+ const withMDX = createMDX({
859
+ extension: /\.mdx?$/,
860
+ // Turbopack only takes MDX plugins as module names, not imported functions.
861
+ options: { remarkPlugins: ["remark-gfm", "@haruhimemoe/ui/remark"] },
862
+ });
863
+
864
+ export default withMDX({ pageExtensions: ["ts", "tsx", "md", "mdx"] });
865
+ ```
866
+
867
+ ```tsx
868
+ // mdx-components.tsx
869
+ import { mdxComponents } from "@haruhimemoe/ui/mdx";
870
+ import "@haruhimemoe/ui/shiki"; // optional: see "Code highlighting" below
871
+
872
+ export function useMDXComponents(components) {
873
+ return { ...mdxComponents, ...components };
874
+ }
875
+ ```
876
+
877
+ `remark-gfm` isn't ui's dependency (pin it yourself); without it `@next/mdx` has no pipe tables, and a table with no GFM stays a paragraph.
878
+
879
+ **With `react-markdown`:**
880
+
881
+ ```tsx
882
+ import { mdxComponents } from "@haruhimemoe/ui/mdx";
883
+ import remarkHaruhime from "@haruhimemoe/ui/remark";
884
+ import remarkGfm from "remark-gfm";
885
+ import Markdown from "react-markdown";
886
+
887
+ <Markdown components={mdxComponents} remarkPlugins={[remarkGfm, remarkHaruhime]}>
888
+ {body}
889
+ </Markdown>;
890
+ ```
891
+
892
+ `mdxComponents` must render in a Server Component: `CodeBlock` (its `pre` override hands off to) is async, and a client component can't render an async one. A client-side renderer, like a live editor preview, can't use `pre` from this map; pass your own synchronous override for that case.
893
+
894
+ If you sanitize the output (`rehype-sanitize` or your own schema), allow `className` on `code` matching `/^language-/`, `dataMeta` on `code`, `dataCallout` on `blockquote`, and `id` on `h2`/`h3`: the remark plugin writes these, and `mdxComponents` reads them back. rehype-sanitize's default `clobberPrefix` renames those heading ids to `user-content-…`; pass `clobberPrefix: ""` (what haruhime.moe uses) to keep the plain id, or live with the prefix and update any hand-written anchor links to match.
895
+
896
+ #### `mdxComponents`
897
+
898
+ The element overrides apps pass to `@next/mdx`'s `useMDXComponents` or `react-markdown`'s `components`: `{ a, blockquote, h2, h3, pre, table }`. Spread it and add your own (`{ ...mdxComponents, Example: LiveExample }`).
899
+
900
+ | Element | Renders |
901
+ | --- | --- |
902
+ | `a` | `https://` opens in a new tab (`rel="noopener noreferrer"`); a same-page `#hash` is a plain anchor; everything else goes through `AutoLink` (`next/link`, or a plain `<a>` off-site). |
903
+ | `h2`, `h3` | The heading with its id (from the remark plugin, or a `slugify` of its own text) and, beside it, a `#` anchor link (`aria-label="Link to section: …"`, always visible in `c4`, turning `h1` on hover, never only on hover). |
904
+ | `pre` | Reads its single `code` child (text, `language-x` class, the fence's meta) and renders `CodeBlock`. Anything else (a `pre` with no single `code` child) renders as a plain, focusable `<pre>`. |
905
+ | `table` | The table wrapped in a focusable, named scroll region: `<div role="group" tabIndex={0} aria-label="…">`, labelled by the table's `<caption>` text, or `"Table"` without one. |
906
+ | `blockquote` | A blockquote the remark plugin marked `data-callout` renders as `Callout` of that type; any other blockquote renders plainly. |
907
+
908
+ #### `CodeBlock`
909
+
910
+ An async Server Component: a header bar (the fence's `title`, or the language name, or `"Code"`) with a copy button, over a `<pre role="group" tabIndex={0}>` of lines. Every native `<div>` prop goes on the wrapper except `code`, `lang`, `title` and `highlight`.
911
+
912
+ | Prop | Type | Default | What it does |
913
+ | --- | --- | --- | --- |
914
+ | `code` | `string` | required | The text. Any line endings (`\r\n`, a trailing newline) are normalized; a lone trailing newline doesn't add a phantom last line. |
915
+ | `lang` | `string` | none | A Shiki language id or alias (`ts`, `tsx`, `js`, `json`, `bash`/`sh`/`shell`, `css`, `html`, `md`/`markdown`, `diff`, `yaml`/`yml`). Unknown languages render plain. |
916
+ | `title` | `string` | none | Shown in the header instead of the language; also names the `<pre>` and the copy button ("Copy x.ts"). |
917
+ | `highlight` | `readonly number[]` | `[]` | 1-based line numbers to mark: an `h1` left border and a `b4` tint (`forced-colors:border-[Highlight]` keeps the mark visible under Windows high contrast). |
918
+
919
+ In MDX, pass these through the fence's meta string instead of writing `CodeBlock` by hand: ` ```ts title="pool.ts" {2,4-5}`. `parseCodeMeta` reads `title="…"` or `title='…'` and `{1,3-5}` ranges (anything else is ignored, and it never throws); the language comes from the fence's own tag.
920
+
921
+ #### Code highlighting
922
+
923
+ `shiki` is an optional peer dependency: apps that never render code don't install it, and ui's only runtime dependency stays `tailwind-merge`. An app that renders code:
924
+
925
+ ```sh
926
+ bun add shiki
927
+ ```
928
+
929
+ ```ts
930
+ // in the module that renders code: mdx-components.tsx, or next to your react-markdown call
931
+ import "@haruhimemoe/ui/shiki";
932
+ ```
933
+
934
+ That import is a side effect: it registers a Shiki core highlighter (`shiki/core` with the no-WASM JS regex engine, loaded through dynamic `import()`) under `@haruhimemoe/ui/mdx`'s `CodeBlock`. The registration lives on `globalThis` for the whole process, not per module graph, but it only exists once the import has actually run: put it in the module that renders code (`mdx-components.tsx`, which reaches every MDX page, or directly beside your `react-markdown` renderer) so it runs before `CodeBlock` does. `package.json`'s `sideEffects` lists `./dist/shiki.js`, so bundlers don't drop the bare import; Turbopack resolves even a dynamic `import("shiki/core")` at build time, which is why highlighting is a separate subpath instead of `CodeBlock` importing it unconditionally.
935
+
936
+ Without the import, `CodeBlock` renders plain, unstyled lines, and in development it logs one `console.warn` per process ("code blocks aren't highlighted…"). A highlighter that fails to load (a missing package, a broken build) falls back the same way, warned once.
937
+
938
+ Tokens render as `<span style={{ color: "var(--shiki-token-…)" }}>`, no injected Shiki HTML, through `--shiki-*` custom properties in `theme.css` (see "Setup" above for loading it). They follow `--hue` like the rest of the palette: `--shiki-foreground` and `--shiki-background` are `c2`/`b6`; `-punctuation` is `c3` and `-comment` is `c4`, the palette tokens as-is; `-keyword` and `-link` are `hsl(var(--hue) 100% 78%)`, the palette's own hue with no offset; `-string`, `-string-expression`, `-constant`, `-function` and `-parameter` are each `calc(var(--hue) + N)`, a hue-offset HSL value. Every one stays at or above 4.5:1 against both `b6` (the block's background) and `b4` (a highlighted line's tint) at every integer hue. `keyword` and `link` get their own fixed lightness instead of reusing `h1`: `h1`'s default (76%) drops to 4.33:1 against `b4` at hue 240, and a `--h1-l` override written for other text shouldn't silently recolor code too.
939
+
940
+ #### `Callout`
941
+
942
+ A labelled aside: a left-border panel (`role="note"`) with an icon and a bold label, usable directly in MDX or rendered by `mdxComponents`' `blockquote` override.
943
+
944
+ | Prop | Type | Default | What it does |
945
+ | --- | --- | --- | --- |
946
+ | `type` | `"note" \| "tip" \| "warning"` | `"note"` | The border color, icon and default label (`h1` / `c2` / `h2` border; "Note" / "Tip" / "Warning"). The type is in the text, never color alone. |
947
+ | `title` | `ReactNode` | the type's label | Replaces the default label. |
948
+ | `children` | `ReactNode` | required | The body. |
949
+
950
+ GitHub-style callout syntax works through the remark plugin: a blockquote whose first paragraph starts with `[!NOTE]`, `[!TIP]`, `[!WARNING]` (case-insensitive; GitHub's `[!IMPORTANT]` maps to `tip`, `[!CAUTION]` to `warning`) becomes a `Callout` of that type, the marker stripped:
951
+
952
+ ```md
953
+ > [!NOTE]
954
+ > Packs are deleted after 90 days of no edits.
955
+ ```
956
+
957
+ Or write `<Callout type="tip">` directly in an `.mdx` file.
958
+
959
+ #### `@haruhimemoe/ui/remark`
960
+
961
+ Plain functions (no React), for `remarkPlugins`. Default export `remarkHaruhime(options?)` runs all three transforms below in order and is what Turbopack needs by module name: `remarkPlugins: ["@haruhimemoe/ui/remark"]`. Named exports `remarkCodeMeta`, `remarkCallouts` and `remarkHeadingIds` run one each, for `react-markdown`'s array form or a custom pipeline.
962
+
963
+ | Option (all default `true`) | Turns off |
964
+ | --- | --- |
965
+ | `codeMeta` | Copying a fenced code block's meta string onto `data-meta`, which `CodeBlock`'s fence syntax (`title=`, `{…}`) reads. |
966
+ | `callouts` | The `[!NOTE]` / `[!TIP]` / `[!WARNING]` blockquote markers. |
967
+ | `headingIds` | Slugged, deduplicated ids on `h2`/`h3` (a heading that already has one keeps it). |
968
+
969
+ `slugify(text)` and `createSlugger()` are also exported: lowercase, Unicode-aware (letters, marks, digits and underscores from any script survive; everything else but whitespace and hyphens is dropped), spaces to hyphens, repeats suffixed `-1`, `-2` like GitHub's own heading anchors. `@haruhimemoe/ui/mdx` re-exports `slugify` for apps that build their own heading links.
970
+
971
+ #### Accessibility
972
+
973
+ - The `<pre>` `CodeBlock` renders and the `<div>` `mdxComponents`' `table` wraps a table in are both `role="group"` with `tabIndex={0}` and an `aria-label` (`pre` may not be named on its own): keyboard users can reach a sideways scroll that a mouse would otherwise require. Do the same for any `pre` you render yourself inside `Prose`.
974
+ - Heading anchor links are always visible (`c4`, turning `h1` on hover), never hover-only, and at least 24px; their `aria-label` ("Link to section: …") keeps the heading's own accessible name as its own text.
975
+ - `CodeCopyButton` reports "Copied" or "Copy failed" through a live region mounted before use, like `CopyButton`.
976
+ - `Callout` is `role="note"` with the type in text, not color alone; highlighted code lines keep a `forced-colors` border so Windows high contrast mode still shows them.
977
+
978
+ `Prose` (in "Basics" above) styles a direct-child `pre` with its own fence look, so `CodeBlock` (which isn't a direct child; `mdxComponents`' `pre` override renders it) is untouched when both are in play. `Prose` also styles `blockquote`.
979
+
980
+ ### Palette (client)
981
+
982
+ 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.
983
+
984
+ Mount it once, from a client file, since commands carry functions. The button goes in `SiteHeader`'s `actions`:
985
+
986
+ ```tsx
987
+ // src/components/Palette.tsx
988
+ "use client";
989
+
990
+ import { type Command, CommandPalette, siteCommands } from "@haruhimemoe/ui";
991
+ import { HEADER_LINKS } from "@/constants/nav";
992
+
993
+ const COMMANDS: Command[] = [
994
+ ...siteCommands({ pages: HEADER_LINKS, tools: "packs", repo: "https://github.com/haruhimemoe/packs.haruhime.moe" }),
995
+ { id: "pack.new", title: "New pack", group: "Packs", shortcut: "g n", run: (ctx) => ctx.navigate("/new") },
996
+ ];
997
+
998
+ export function Palette() {
999
+ return <CommandPalette storageKey="packs" commands={COMMANDS} />;
1000
+ }
1001
+ ```
1002
+
1003
+ ```tsx
1004
+ // app/layout.tsx
1005
+ <SiteHeader brand={...} links={HEADER_LINKS} actions={<CommandPaletteButton>Search</CommandPaletteButton>} />
1006
+ <Palette />
1007
+ ```
1008
+
1009
+ #### `CommandPalette`
1010
+
1011
+ The dialog. Renders nothing until opened, then a native `<dialog>` (modal, backdrop, scroll locked) with a combobox over a listbox. Mount one per app.
1012
+
1013
+ | Prop | Type | Default | What it does |
1014
+ | --- | --- | --- | --- |
1015
+ | `commands` | `readonly Command[]` | required | The root rows. Read on every open, so a command's `when` and titles can change. |
1016
+ | `providers` | `readonly Provider[]` | none | Searched at the root as you type, once the query reaches each one's `minLength`. |
1017
+ | `storageKey` | `string` | `"default"` | Namespaces the recents in `localStorage` (`haruhime:palette:<key>`). |
1018
+ | `hotkey` | `string` | `"mod+k"` | The toggle, in shortcut syntax. `mod` is ⌘ on a Mac and Ctrl elsewhere. |
1019
+ | `placeholder` | `string` | `"Search commands…"` | The root input's placeholder. |
1020
+ | `label` | `string` | `"Command palette"` | The dialog's and the input's accessible name. |
1021
+ | `calculator` | `boolean` | `true` | A `= 42` row for a query that computes (`2*21`), first in the list; Enter copies the result. |
1022
+ | `recents` | `boolean` | `true` | A Recent group of the last five commands run, and a ranking boost by how often each ran. |
1023
+ | `className` | `string` | none | Classes for the panel. |
1024
+
1025
+ 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.
1026
+
1027
+ 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.
1028
+
1029
+ #### `Command`
1030
+
1031
+ | Field | Type | What it does |
1032
+ | --- | --- | --- |
1033
+ | `id` | `string` | Unique in the app. Recents are stored by it, so keep it stable when a title changes. |
1034
+ | `title` | `string` | The row. |
1035
+ | `subtitle?` | `string` | A smaller second line. |
1036
+ | `icon?` | `ReactNode` | A 20px slot before the title. |
1037
+ | `keywords?` | `string[]` | Matched at a lower weight than the title. |
1038
+ | `group?` | `string` | The heading the row sits under. Default `"Commands"`. |
1039
+ | `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. |
1040
+ | `when?` | `(ctx) => boolean` | Hidden when false. Read on every render of the list. |
1041
+ | `run?` | `(ctx, args) => void \| Promise<void>` | What it does. A rejection is logged; the palette stays usable. |
1042
+ | `page?` | `Page` | Instead of `run`: Enter pushes this page (its `commands` and `providers`), with the page's title as a crumb. |
1043
+ | `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]`. |
1044
+ | `closeOnRun?` | `boolean` | Default `true`. |
1045
+
1046
+ `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).
1047
+
1048
+ #### `Provider`
1049
+
1050
+ `{ 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.
1051
+
1052
+ #### `openCommandPalette(page?)`
1053
+
1054
+ 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.
1055
+
1056
+ #### `CommandPaletteButton` (client)
1057
+
1058
+ 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`.
1059
+
1060
+ #### `siteCommands(options)`
1061
+
1062
+ The defaults every tool gets, in order: Navigate, Page, Account, Help. Pass only what the app has.
1063
+
1064
+ | Option | Type | What it builds |
1065
+ | --- | --- | --- |
1066
+ | `pages` | `SiteLinkItem[]` | "Go to <label>" per linked nav item (`site.go.<slug>`). |
1067
+ | `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. |
1068
+ | `repo` | `string` | "Open on GitHub" (`site.github`) and "Report a bug" (`site.report`, the repo's new-issue page). |
1069
+ | `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. |
1070
+ | `include` | `("navigate" \| "page" \| "account" \| "help")[]` | Which groups. Default all. |
1071
+
1072
+ 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.
1073
+
1074
+ #### Calculator
1075
+
1076
+ `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.
1077
+
1078
+ #### Accessibility
1079
+
1080
+ 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.
1081
+
848
1082
  ### Tables
849
1083
 
850
1084
  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 +1123,22 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
889
1123
  The target is WCAG 2.2 AA. House rules, which every component follows and your own code around them should too:
890
1124
 
891
1125
  - **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).
1126
+ - **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
1127
  - **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
1128
  - **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
1129
  - **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
1130
  - **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.
1131
+ - **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
1132
  - **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
1133
  - **Scrollable regions take focus.** `Table`'s wrapper is a focusable named section. Do the same for a `pre` inside `Prose`.
899
1134
  - **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
1135
  - **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
1136
  - **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.
1137
+ - **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
1138
 
904
1139
  ## Compatibility
905
1140
 
906
- | | Supported |
1141
+ | Requirement | Supported |
907
1142
  | --- | --- |
908
1143
  | Next.js | 16 (app router). Components use `next/link` and `next/navigation`. |
909
1144
  | React | 19 |
@@ -914,7 +1149,7 @@ The target is WCAG 2.2 AA. House rules, which every component follows and your o
914
1149
 
915
1150
  ## Changelog and contributing
916
1151
 
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).
1152
+ 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
1153
 
919
1154
  ## License
920
1155
 
@@ -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
  }
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * @file src/components/basics/Prose.tsx
3
3
  * @desc Long-form typography for MDX, docs and legal text, styled with the osu!-web tokens.
4
- * Styles the plain elements inside it (headings, links, lists, code, tables). The element
5
- * that opens the block gets no top margin, so a leading heading sits flush.
4
+ * Styles the plain elements inside it (headings, links, lists, code, tables, blockquotes).
5
+ * The element that opens the block gets no top margin, so a leading heading sits flush. Any
6
+ * `pre` at any depth (including one nested inside `li` or `blockquote`) gets Prose's fence
7
+ * styles, except CodeBlock's own `pre` (`role="group"`, the `./mdx` subpath's fenced code
8
+ * renderer), which keeps its own look untouched.
6
9
  * @author David @dvhsh (https://dvh.sh)
7
10
  * @created Wed Sep 23, 2026
8
- * @modified Thu Sep 24, 2026
11
+ * @modified Sat Oct 3, 2026
9
12
  */
10
13
  import type { ComponentProps } from "react";
11
14
  /** Every native `<div>` prop (including `ref`). */
@@ -13,8 +16,8 @@ export type ProseProps = ComponentProps<"div">;
13
16
  /**
14
17
  * @function Prose
15
18
  * @param props {ProseProps} native div props; children are the rendered Markdown or MDX
16
- * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
17
- * tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
18
- * which outranks the h2 and h3 margins by specificity.
19
+ * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code,
20
+ * tables and blockquotes inside it. Its first child's top margin is zeroed
21
+ * (`[&>:first-child]:mt-0`), which outranks the h2 and h3 margins by specificity.
19
22
  */
20
23
  export declare function Prose({ className, ...props }: ProseProps): import("react").JSX.Element;
@@ -3,10 +3,10 @@ import { cx } from "../../utils/cx.js";
3
3
  /**
4
4
  * @function Prose
5
5
  * @param props {ProseProps} native div props; children are the rendered Markdown or MDX
6
- * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code and
7
- * tables inside it. Its first child's top margin is zeroed (`[&>:first-child]:mt-0`),
8
- * which outranks the h2 and h3 margins by specificity.
6
+ * @returns {JSX.Element} a max-w-3xl `<div>` that styles the headings, links, lists, code,
7
+ * tables and blockquotes inside it. Its first child's top margin is zeroed
8
+ * (`[&>:first-child]:mt-0`), which outranks the h2 and h3 margins by specificity.
9
9
  */
10
10
  export function Prose({ className, ...props }) {
11
- return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&>:first-child]:mt-0 [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre]:mt-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-b6 [&_pre]:p-3 [&_pre]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
11
+ return (_jsx("div", { className: cx("max-w-3xl text-c2 leading-relaxed [&>:first-child]:mt-0 [&_a:hover]:text-c1 [&_a]:text-h1 [&_a]:underline [&_blockquote]:mt-3 [&_blockquote]:border-b2 [&_blockquote]:border-l-2 [&_blockquote]:pl-4 [&_blockquote]:text-c3 [&_code]:rounded [&_code]:bg-b4 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[0.9em] [&_h2]:mt-10 [&_h2]:mb-3 [&_h2]:font-bold [&_h2]:text-2xl [&_h2]:text-c1 [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:font-bold [&_h3]:text-c1 [&_h3]:text-lg [&_hr]:my-8 [&_hr]:border-b3 [&_li]:mt-1 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mt-3 [&_pre:not([role=group])]:mt-3 [&_pre:not([role=group])]:overflow-x-auto [&_pre:not([role=group])]:rounded-md [&_pre:not([role=group])]:bg-b6 [&_pre:not([role=group])]:p-3 [&_pre:not([role=group])]:text-sm [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_strong]:text-c1 [&_table]:mt-4 [&_table]:w-full [&_table]:text-sm [&_td]:border-b3 [&_td]:border-b [&_td]:px-2 [&_td]:py-1.5 [&_th]:border-b3 [&_th]:border-b [&_th]:px-2 [&_th]:py-1.5 [&_th]:text-left [&_th]:text-c1 [&_ul]:mt-3 [&_ul]:list-disc [&_ul]:pl-6", className), ...props }));
12
12
  }
@@ -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,24 @@
1
+ /**
2
+ * @file src/components/mdx/Callout.tsx
3
+ * @desc A labelled aside for MDX prose (note, tip, warning): a left-border panel with an icon and
4
+ * a bold label, rendered by MdxBlockquote for a GitHub-style `> [!NOTE]` blockquote, or
5
+ * usable directly in hand-written MDX.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sat Oct 3, 2026
8
+ * @modified Sat Oct 3, 2026
9
+ */
10
+ import type { ComponentProps, ReactNode } from "react";
11
+ import type { CalloutType } from "../../remark/callouts.js";
12
+ export type { CalloutType };
13
+ /** Every native `<div>` prop except `title` (replaced by the callout's own label slot). */
14
+ export type CalloutProps = Omit<ComponentProps<"div">, "title"> & {
15
+ type?: CalloutType | undefined;
16
+ title?: ReactNode;
17
+ };
18
+ /**
19
+ * @function Callout
20
+ * @param props {CalloutProps} the callout type (default "note"), an optional title replacing
21
+ * the type's default label, native div props and children
22
+ * @returns {JSX.Element} a bordered, labelled panel announced as a note
23
+ */
24
+ export declare function Callout({ type, title, className, children, ...props }: CalloutProps): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from "../../utils/cx.js";
3
+ const LABEL = { note: "Note", tip: "Tip", warning: "Warning" };
4
+ const BORDER = {
5
+ note: "border-h1",
6
+ tip: "border-c2",
7
+ warning: "border-h2",
8
+ };
9
+ const ICON = {
10
+ note: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2Zm0-8h-2V7h2Z",
11
+ tip: "M9 21h6v-2H9Zm3-19a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2Z",
12
+ warning: "M1 21h22L12 2Zm12-3h-2v-2h2Zm0-4h-2v-4h2Z",
13
+ };
14
+ /**
15
+ * @function Callout
16
+ * @param props {CalloutProps} the callout type (default "note"), an optional title replacing
17
+ * the type's default label, native div props and children
18
+ * @returns {JSX.Element} a bordered, labelled panel announced as a note
19
+ */
20
+ export function Callout({ type = "note", title, className, children, ...props }) {
21
+ return (_jsxs("div", { role: "note", className: cx("mt-4 rounded-md border-l-4 bg-b5 px-4 py-3 text-c2", BORDER[type], className), ...props, children: [_jsxs("p", { className: "mt-0! flex items-center gap-2 font-bold text-c1", children: [_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", className: "size-4 shrink-0 fill-current", children: _jsx("path", { d: ICON[type] }) }), title ?? LABEL[type]] }), _jsx("div", { className: "[&>:first-child]:mt-1", children: children })] }));
22
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file src/components/mdx/CodeBlock.tsx
3
+ * @desc A fenced code block rendered with Shiki highlighting when the app registered it (by
4
+ * importing `@haruhimemoe/ui/shiki`) and the language is one of the bundled set, plain text
5
+ * otherwise. Async so it can await the lazily-loaded highlighter from highlighter.ts.
6
+ * Server-safe: the only client piece is the copy button (CodeCopyButton).
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sat Oct 3, 2026
9
+ * @modified Sat Oct 3, 2026
10
+ */
11
+ import type { ComponentProps } from "react";
12
+ /** The code (any line endings, trailing newline optional), language, title, highlighted lines
13
+ * and every other native `<div>` prop (spread onto the wrapper). */
14
+ export type CodeBlockProps = Omit<ComponentProps<"div">, "title" | "children"> & {
15
+ code: string;
16
+ lang?: string | undefined;
17
+ title?: string | undefined;
18
+ highlight?: readonly number[] | undefined;
19
+ };
20
+ /**
21
+ * @function CodeBlock
22
+ * @param props {CodeBlockProps} the code, optional language, title, highlighted line numbers and
23
+ * wrapper class
24
+ * @returns {Promise<JSX.Element>} a header (name and copy button) over a highlighted `<pre>`
25
+ */
26
+ export declare function CodeBlock({ code, lang, title, highlight, className, ...rest }: CodeBlockProps): Promise<import("react").JSX.Element>;