@haruhimemoe/ui 0.11.2 → 0.12.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 (75) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +64 -6
  3. package/dist/components/actions/AsyncButton.d.ts +1 -1
  4. package/dist/components/actions/AsyncButton.js +3 -2
  5. package/dist/components/basics/AutoLink.d.ts +8 -4
  6. package/dist/components/basics/AutoLink.js +12 -5
  7. package/dist/components/basics/Badge.d.ts +1 -1
  8. package/dist/components/basics/Badge.js +3 -3
  9. package/dist/components/basics/Disclosure.d.ts +1 -1
  10. package/dist/components/basics/Disclosure.js +2 -2
  11. package/dist/components/basics/Notice.d.ts +1 -1
  12. package/dist/components/basics/Notice.js +6 -6
  13. package/dist/components/basics/Tabs.d.ts +1 -1
  14. package/dist/components/basics/Tabs.js +3 -3
  15. package/dist/components/basics/Text.d.ts +26 -0
  16. package/dist/components/basics/Text.js +17 -0
  17. package/dist/components/basics/buttonStyles.d.ts +2 -2
  18. package/dist/components/basics/buttonStyles.js +9 -6
  19. package/dist/components/basics/cardStyles.d.ts +4 -3
  20. package/dist/components/basics/cardStyles.js +4 -3
  21. package/dist/components/basics/textStyles.d.ts +28 -0
  22. package/dist/components/basics/textStyles.js +18 -0
  23. package/dist/components/basics/textTones.d.ts +17 -0
  24. package/dist/components/basics/textTones.js +24 -0
  25. package/dist/components/basics/useMotionAllowed.d.ts +16 -0
  26. package/dist/components/basics/useMotionAllowed.js +41 -0
  27. package/dist/components/brand/BrandPage.js +2 -1
  28. package/dist/components/content/ContentNav.js +2 -2
  29. package/dist/components/content/CopyMarkdownButton.js +3 -2
  30. package/dist/components/filters/FilterPanel.d.ts +1 -1
  31. package/dist/components/filters/FilterPanel.js +2 -2
  32. package/dist/components/filters/GroupFrame.d.ts +4 -2
  33. package/dist/components/filters/GroupFrame.js +1 -1
  34. package/dist/components/filters/chipStyles.d.ts +7 -6
  35. package/dist/components/filters/chipStyles.js +8 -6
  36. package/dist/components/forms/CharCounter.d.ts +1 -1
  37. package/dist/components/forms/CharCounter.js +6 -1
  38. package/dist/components/forms/Checkbox.d.ts +4 -3
  39. package/dist/components/forms/Checkbox.js +5 -4
  40. package/dist/components/forms/FieldFrame.d.ts +9 -4
  41. package/dist/components/forms/FieldFrame.js +6 -5
  42. package/dist/components/forms/RadioGroup.d.ts +4 -2
  43. package/dist/components/forms/RadioGroup.js +4 -4
  44. package/dist/components/forms/ReportDisclosure.d.ts +1 -1
  45. package/dist/components/forms/ReportDisclosure.js +2 -2
  46. package/dist/components/forms/Select.d.ts +4 -4
  47. package/dist/components/forms/Select.js +4 -4
  48. package/dist/components/forms/TextInput.d.ts +3 -3
  49. package/dist/components/forms/TextInput.js +3 -3
  50. package/dist/components/forms/Textarea.d.ts +6 -4
  51. package/dist/components/forms/Textarea.js +3 -3
  52. package/dist/components/forms/TypeToConfirm.d.ts +1 -1
  53. package/dist/components/forms/TypeToConfirm.js +2 -2
  54. package/dist/components/forms/fieldStyles.d.ts +5 -3
  55. package/dist/components/forms/fieldStyles.js +6 -4
  56. package/dist/components/mdx/CodeBlock.d.ts +1 -1
  57. package/dist/components/mdx/CodeBlock.js +1 -1
  58. package/dist/components/mdx/CodeCopyButton.d.ts +1 -1
  59. package/dist/components/mdx/CodeCopyButton.js +2 -2
  60. package/dist/components/osu/ModBadge.d.ts +14 -9
  61. package/dist/components/osu/ModBadge.js +42 -14
  62. package/dist/components/palette/CommandPalette.d.ts +1 -1
  63. package/dist/components/palette/CommandPalette.js +2 -2
  64. package/dist/components/palette/PaletteInput.d.ts +1 -1
  65. package/dist/components/palette/PaletteInput.js +3 -2
  66. package/dist/components/palette/PaletteRow.d.ts +1 -1
  67. package/dist/components/palette/PaletteRow.js +3 -3
  68. package/dist/components/shell/HeaderMenu.d.ts +1 -1
  69. package/dist/components/shell/HeaderMenu.js +3 -3
  70. package/dist/components/shell/LinkTabs.d.ts +1 -1
  71. package/dist/components/shell/LinkTabs.js +1 -1
  72. package/dist/index.d.ts +4 -1
  73. package/dist/index.js +4 -1
  74. package/dist/theme.css +56 -6
  75. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-10-04
10
+
11
+ ### Added
12
+
13
+ - Motion tokens in `theme.css`: `duration-short` (150ms), `duration-medium` (250ms), `duration-long` (400ms), `ease-standard`, `ease-enter` and `ease-exit`. Every `transition-*` now reads `duration-short` and `ease-standard`, the values it had.
14
+ - One reduced-motion rule: under `prefers-reduced-motion: reduce` every animation and transition finishes in 0.01ms and smooth scrolling turns off, in the kit and in the app's own markup. `data-motion="essential"` keeps an element's motion.
15
+ - High contrast: under `prefers-contrast: more`, `c2`, `c3`, `c4` and `h1` get 8 points lighter and `h2` 4 points darker, through `--contrast-lift` (`0%` opts out).
16
+ - The `coarse:` variant (`@media (pointer: coarse)`), for a touchscreen as the main pointer.
17
+ - `useMotionAllowed()` (client): true when the visitor allows motion, false on the server, during hydration and under reduced motion, live. Lifted from haruhime.moe's homepage banner.
18
+ - `Text` and `textClasses`: a line of text in six tones (`default` c2, `muted` c3, `subtle` c4, `error`, `warning` amber-300, `success`) and three sizes, for the error, warning and muted text apps wrote by hand.
19
+ - `download` on `ButtonLink` and `TextLink` renders a plain `<a download>` (no prefetch, no client routing), for any value but `false`.
20
+ - `hideLabel` on `TextInput`, `Select`, `Textarea`, `Checkbox` and `RadioGroup`: the label (legend for RadioGroup) is hidden visually but still names the control, with no gap left above it.
21
+ - `ModBadge` `color` (`ModBadgeColor`): any of the six bucket colors, the ten @haruhimemoe/pool palette colors or `neutral`, overriding the color the mod's letters pick. A value outside the list falls back to the bucket.
22
+ - 44px touch targets on a coarse pointer: chips and choice chips, checkbox and radio rows, Disclosure and HeaderMenu buttons and menu items, Tabs and LinkTabs, palette rows, FilterPanel's toggle, code copy buttons and ContentNav links.
23
+ - Under more contrast, cards, chips, neutral badges and the tabs track get a `c4` edge, and code blocks, menus, the palette and its key hints a `c4` border. In forced colors, cards, chips, badges, mod badges and the tabs track keep a 1px border; an unavailable chip is GrayText.
24
+
25
+ ### Changed
26
+
27
+ - `c2`, `c3`, `c4`, `h1` and `h2` are defined through lightness variables (`--c2-l`, `--c3-l` and `--c4-l` join `--h1-l` and `--h2-l`) plus the contrast lift. The default colors are unchanged.
28
+ - `Notice`, field errors, `CharCounter`, `AsyncButton`'s failure text and the palette's input error use Text's tones, so they get one step lighter under more contrast.
29
+ - **Button no longer stretches** in a flex column or a grid cell: `buttonClasses()` adds `w-fit` (Button, ButtonLink, AsyncButton, CopyButton, Pagination). Pass `className="w-full"` where a full-width button is wanted. `self-start` on buttons is now a no-op.
30
+ - Buttons at `md` are 44px tall on touch screens (`coarse:h-11`). Secondary and ghost buttons get a `c4` edge under more contrast; every button keeps a 1px border in forced colors, and a disabled one uses the system's GrayText there.
31
+ - `CopyMarkdownButton` follows Button's classes again (a test now pins its copy).
32
+ - `BrandPage`'s file links are `TextLink download`.
33
+ - Fields are 44px tall with 16px text on touch screens (no iOS zoom), checkbox and radio rows 44px tall, and a field's border is `c4` under more contrast.
34
+ - `RangeSlider`'s value boxes also become 44px tall with 16px text on touch screens: they use `fieldClasses`, the same field look.
35
+
9
36
  ## [0.11.2] - 2026-10-04
10
37
 
11
38
  ### Fixed
@@ -165,7 +192,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
165
192
  - `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`).
166
193
  - 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).
167
194
 
168
- [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.11.2...HEAD
195
+ [unreleased]: https://github.com/haruhimemoe/ui/compare/v0.12.0...HEAD
196
+ [0.12.0]: https://github.com/haruhimemoe/ui/compare/v0.11.2...v0.12.0
169
197
  [0.11.2]: https://github.com/haruhimemoe/ui/compare/v0.11.1...v0.11.2
170
198
  [0.11.1]: https://github.com/haruhimemoe/ui/compare/v0.11.0...v0.11.1
171
199
  [0.11.0]: https://github.com/haruhimemoe/ui/compare/v0.10.0...v0.11.0
package/README.md CHANGED
@@ -6,7 +6,7 @@ React components for the haruhime.moe osu! tools on Next.js. It ships the osu!-w
6
6
 
7
7
  See every component in its states at [haruhime.moe/ui](https://www.haruhime.moe/ui). The page names the version it runs.
8
8
 
9
- This README describes version 0.11.0. Anything marked "since 0.11.0" is not in 0.10.0, anything marked "since 0.10.0" is not in 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.
9
+ This README describes version 0.12.0. Anything marked "since 0.12.0" is not in 0.11.x, anything marked "since 0.11.0" is not in 0.10.0, anything marked "since 0.10.0" is not in 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
 
@@ -77,13 +77,19 @@ const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito", display:
77
77
  }
78
78
  ```
79
79
 
80
- At some hues the defaults drop below 4.5:1 contrast, so check yours. Two variables fix it:
80
+ At some hues the defaults drop below 4.5:1 contrast, so check yours. Two variables fix it (and `--c2-l`, `--c3-l`, `--c4-l` set the content steps' lightness, default `90%`, `80%`, `70%`):
81
81
 
82
82
  - `--h2-l` sets the lightness of `h2` (default `45%`). White text on `h2` (primary buttons, the skip link) is under 4.5:1 for hues from about 23 to 205. Use `42%` at hue 200, `35%` at hue 150, or `31%` for any hue.
83
83
  - `--h1-l` sets the lightness of `h1` (default `76%` since 0.7.0, `70%` before). At `76%`, `h1` text on `b5` (links in cards and prose) is at or above 4.5:1 at every hue. On `b4` ("Clear filters" in a filter panel) it dips under for hues from about 238 to 248. Use `77%` there.
84
84
 
85
85
  The theme is dark only (`color-scheme: dark`).
86
86
 
87
+ **5. Motion, contrast and touch (since 0.12.0).** The theme follows three browser settings. There is no in-app toggle.
88
+
89
+ - **Reduced motion.** When the visitor asks for reduced motion (`prefers-reduced-motion: reduce`), every animation and transition finishes in 0.01ms and smooth scrolling turns off, in the kit and in your own markup. Put `data-motion="essential"` on an element whose movement is the message (a progress indicator): it and its children keep their motion. Tokens: `duration-short` (150ms: hovers, presses, color changes, and the default for every `transition-*`), `duration-medium` (250ms: panels, dialogs), `duration-long` (400ms: large surfaces), with `ease-standard` (the default), `ease-enter` and `ease-exit`. For motion JavaScript drives (an autoplay video, a canvas), read `useMotionAllowed()`.
90
+ - **More contrast.** Under `prefers-contrast: more`, `c2`, `c3`, `c4` and `h1` get 8 points lighter and `h2` 4 points darker. Cards, chips, secondary and ghost buttons, neutral badges and the tabs track get a `c4` edge; fields, code blocks and menus swap their `b3` border for `c4`. The lift adds to your `--h1-l` and `--h2-l`. Set `--contrast-lift: 0%` on `:root` to opt out.
91
+ - **Touch.** `coarse:` is a variant for a touchscreen as the main pointer (`@media (pointer: coarse)`, the same query as Tailwind's `pointer-coarse:`). Buttons at `md`, chips, checkbox and radio rows, tabs, menu items, palette rows and fields grow to 44px there, and fields use 16px text so iOS Safari doesn't zoom in. Undo it for one control with a class like `coarse:h-9`. A touchscreen laptop driven by its trackpad keeps the dense layout.
92
+
87
93
  ## Example
88
94
 
89
95
  ```tsx
@@ -180,6 +186,8 @@ A pill button. Every native `<button>` prop.
180
186
  | `size` | `"md" \| "lg"` | `"md"` | Height, padding and text size. |
181
187
  | `type` | `"button" \| "submit" \| "reset"` | `"button"` | Never submits a form unless you ask for `"submit"`. |
182
188
 
189
+ Since 0.12.0 a button sits at its content width (`w-fit`), also in a flex column or a grid cell; pass `className="w-full"` for a full-width one. At `md` it is 44px tall on a coarse pointer.
190
+
183
191
  #### `ButtonLink`
184
192
 
185
193
  A link that looks like `Button`. Every `next/link` prop (`href`, `prefetch`, `replace`, `scroll`, `target`, `rel`...), plus `variant` and `size` as on `Button`.
@@ -187,6 +195,13 @@ A link that looks like `Button`. Every `next/link` prop (`href`, `prefetch`, `re
187
195
  - A string `href` with a scheme (`https:`, `mailto:`) or starting with `//` renders a plain `<a>`, and `next/link`'s own props are dropped. The href is read the way the browser reads it: leading spaces don't count and a backslash counts as a slash, so `/\host` and `\\host` are off-site too (since 0.4.0).
188
196
  - That plain `<a>` with `target="_blank"` and no `rel` gets `rel="noreferrer"`. A `rel` you pass always wins. Internal links get only the `rel` you pass.
189
197
 
198
+ Since 0.12.0, `download` (any value but `false`) renders a plain `<a download>`: no prefetch of an API route or a large file, no client routing. An external href also renders a plain `<a>`, with `rel="noreferrer"` in a new tab.
199
+
200
+ ```tsx
201
+ <ButtonLink href="/api/me/export" download variant="secondary">Download my data</ButtonLink>
202
+ <ButtonLink href="https://osu.ppy.sh" target="_blank">osu!</ButtonLink>
203
+ ```
204
+
190
205
  #### `buttonClasses`
191
206
 
192
207
  `buttonClasses({ variant?, size?, className? }): string` returns the `Button` classes, for elements the components don't cover. Types: `ButtonVariant`, `ButtonSize`, `ButtonClassOptions`.
@@ -233,6 +248,25 @@ A `status` region is only reliably announced when its content changes while it i
233
248
 
234
249
  An error notice (`role="alert"`) is announced either way.
235
250
 
251
+ #### `Text` and `textClasses` (since 0.12.0)
252
+
253
+ A line of text in a tone and a size, instead of hand-written color classes. Every native `<p>` prop. Types: `TextTone`, `TextSize`, `TextClassOptions`.
254
+
255
+ | Prop | Type | Default | What it does |
256
+ | --- | --- | --- | --- |
257
+ | `tone` | `"default" \| "muted" \| "subtle" \| "error" \| "warning" \| "success"` | `"default"` | `c2`, `c3`, `c4`, rose, amber or emerald. The status tones get one step lighter under more contrast. |
258
+ | `size` | `"xs" \| "sm" \| "base"` | `"sm"` | The text size. |
259
+ | `bold` | `boolean` | `false` | `font-bold`. |
260
+ | `as` | `"p" \| "span" \| "div"` | `"p"` | The element. |
261
+
262
+ `textClasses({ tone, size, bold, className })` returns the same classes for an element `Text` can't be (an `<output>`, a `<time>`, a class map). `Text` is styling only, not a live region: a message that appears after an action still goes in `Notice live` or a mounted `role="status"`. Error and warning text must say what is wrong in words.
263
+
264
+ ```tsx
265
+ <Text tone="muted">Last updated Oct 4.</Text>
266
+ <Text role="alert" tone="error" bold>That pack key is not valid.</Text>
267
+ <output className={textClasses({ tone: "success" })}>Saved.</output>
268
+ ```
269
+
236
270
  #### `Prose`
237
271
 
238
272
  Long-form typography for MDX, docs and legal pages. A `max-w-3xl` `<div>` that styles the `h2`, `h3`, `p`, `a`, `strong`, `ul`, `ol`, `li`, `code`, `pre`, `hr` and `table` elements inside it. Every native `<div>` prop. A `pre` scrolls sideways, so give it `tabIndex={0}` (through your Markdown renderer's `components` map) so keyboard users can reach the scroll; CSS can't add that.
@@ -281,9 +315,11 @@ Since 0.4.0. A text link: `next/link` inside the app, a plain `<a>` off-site (wi
281
315
  <TextLink href={`/packs/${pack.id}`} variant="plain">{pack.name}</TextLink>
282
316
  ```
283
317
 
318
+ `download` works the same way (since 0.12.0): `<TextLink href="/brand/haruhime-palette.json" download>Download palette (JSON)</TextLink>`.
319
+
284
320
  #### `linkClasses`
285
321
 
286
- Since 0.4.0. `linkClasses({ variant?, className? }): string` returns the `TextLink` classes, for an element that should look like one (a `<button>` that reads as a link, say). Type: `LinkClassOptions`.
322
+ Since 0.4.0. `linkClasses({ variant?, className? }): string` returns the `TextLink` classes, for an element that should look like one (a `<button>` that reads as a link, say). Type: `LinkClassOptions`. Downloads use `TextLink download` since 0.12.0.
287
323
 
288
324
  #### `Badge`
289
325
 
@@ -312,6 +348,21 @@ The panels are yours. `tabId(prefix, tab)` and `tabPanelId(prefix, tab)` (server
312
348
  <div role="tabpanel" id={tabPanelId("ed", tab)} aria-labelledby={tabId("ed", tab)}>...</div>
313
349
  ```
314
350
 
351
+ #### `useMotionAllowed` (client, since 0.12.0)
352
+
353
+ `useMotionAllowed()` is `true` when the visitor allows motion. It is `false` on the server, during hydration, without `matchMedia`, and while the visitor asks for reduced motion, and it follows the setting live. Use it for motion JavaScript drives (an autoplay video, a canvas, `element.animate()`). CSS transitions and animations already stop through the theme, so components don't need it for those.
354
+
355
+ ```tsx
356
+ "use client";
357
+
358
+ import { useMotionAllowed } from "@haruhimemoe/ui";
359
+
360
+ export function Hero() {
361
+ const motion = useMotionAllowed();
362
+ return motion ? <video autoPlay loop muted playsInline src="/loop.webm" /> : null;
363
+ }
364
+ ```
365
+
315
366
  ### Forms
316
367
 
317
368
  The fields render a label, the control, an optional hint and an optional error, wired together for screen readers. They are Server Components: you pass the `id`, so they need no generated ids.
@@ -322,11 +373,12 @@ Shared props (type `FieldProps`), taken by `TextInput`, `Textarea`, `Select` and
322
373
  | --- | --- | --- | --- |
323
374
  | `id` | `string` | required | The control's id. The label points at it. The hint gets `<id>-hint` and the error `<id>-error`. |
324
375
  | `label` | `ReactNode` | required | The visible label. |
376
+ | `hideLabel` | `boolean` | `false` | Since 0.12.0. Hides the label visually; it still names the control. No gap is left above the control. |
325
377
  | `hint` | `ReactNode` | none | Help text in a `<div>`, linked with `aria-describedby`. On `Checkbox` the hint sits inline inside the label, so keep it to text there. |
326
378
  | `error` | `ReactNode` | none | Error text in a `role="status"` `<div>` (`text-rose-300`; `role="alert"` before 0.7.0), so a list of errors is fine. Sets `aria-invalid` and links the text with `aria-describedby`. |
327
379
  | `wrapperClassName` | `string` | none | Classes for the wrapper around the label, control, hint and error, for layout (`min-w-48 flex-1`). |
328
380
 
329
- `className` goes on the control itself. Your own `aria-describedby` is kept after the hint and error ids.
381
+ `className` goes on the control itself. Your own `aria-describedby` is kept after the hint and error ids. Fields are 44px tall with 16px text on a coarse pointer (since 0.12.0).
330
382
 
331
383
  #### `TextInput`
332
384
 
@@ -355,6 +407,7 @@ Since 0.4.0. A native radio group on the `Checkbox` look: a `<fieldset>` named b
355
407
  | Prop | Type | Default | What it does |
356
408
  | --- | --- | --- | --- |
357
409
  | `label` | `ReactNode` | required | The legend. |
410
+ | `hideLabel` | `boolean` | `false` | Since 0.12.0. Hides the legend visually; it still names the group. |
358
411
  | `options` | `readonly RadioOption[]` | required | `{ value: string; label: ReactNode; hint?: ReactNode; disabled?: boolean }` for each radio. |
359
412
  | `value` / `defaultValue` | `string` | none | The picked value, held by you (`value`, with `onChange`) or by the group (`defaultValue`). |
360
413
  | `onChange` | `(value: string) => void` | none | Gets the picked option's value. |
@@ -694,6 +747,8 @@ One labelled row: the label above the controls on phones, in a `w-28` column on
694
747
  | --- | --- | --- | --- |
695
748
  | `label` | `ReactNode` | required | The row's label. Also names the group. |
696
749
 
750
+ Not the same as the fields' `hideLabel`: inside a `FilterRow`, a `ChipGroup`, `ChoiceChips` or `RangeSlider`'s own `hideLabel` drops its label entirely, because the row names it instead.
751
+
697
752
  #### `FilterPanel` (client)
698
753
 
699
754
  A titled panel of `FilterRow`s with a live result count and a "Clear filters" button. On phones the rows fold behind a button next to the title; from `sm` up they always show. Every native `<section>` prop except `title`.
@@ -963,11 +1018,12 @@ A beatmap's CS, AR, OD, HP, BPM and length as a compact `<dl>`, in that order. S
963
1018
 
964
1019
  #### `ModBadge`
965
1020
 
966
- A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: NM sky, HD amber, HR rose, DT and NC violet, FM emerald, TB orange, with dark text. Anything else is a `b3` pill. Every native `<span>` prop; `children` replace the text, and `className` recolors it (`bg-pink-300` for a custom bucket).
1021
+ A mod pool slot's pill (`NM1`, `HD2`, `TB`), colored by the first two letters: NM sky, HD amber, HR rose, DT and NC violet, FM emerald, TB orange, with dark text. Anything else is a `b3` pill. Every native `<span>` prop; `children` replace the text. A custom bucket picks its color with `color`: `<ModBadge mod={code} color={PALETTE[entry.color].toLowerCase() as ModBadgeColor} />`. `className` still recolors it (`bg-pink-300`) when `color`'s fixed list doesn't fit.
967
1022
 
968
1023
  | Prop | Type | Default | What it does |
969
1024
  | --- | --- | --- | --- |
970
1025
  | `mod` | `string` | required | The mod or slot label. |
1026
+ | `color` | `ModBadgeColor` | from `mod` | Since 0.12.0. Overrides the bucket color: `sky`, `amber`, `rose`, `violet`, `emerald`, `orange` (the buckets), `green`, `teal`, `pink`, `lime`, `cyan`, `fuchsia`, `yellow`, `red`, `indigo`, `stone` (@haruhimemoe/pool's PALETTE, lowercased), or `neutral`. |
971
1027
 
972
1028
  #### `PlayerCard`
973
1029
 
@@ -1283,8 +1339,10 @@ Since 0.4.0. `cx(...classes: ClassValue[]): string` is the class merger every co
1283
1339
  The target is WCAG 2.2 AA. House rules, which every component follows and your own code around them should too:
1284
1340
 
1285
1341
  - **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`.
1286
- - **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).
1342
+ - **Targets are 24px or more, 44px on coarse pointers** (WCAG 2.2 2.5.8, and 2.5.5 on touch): buttons are `h-9` (`h-11` on a touchscreen since 0.12.0), 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). On a coarse pointer (since 0.12.0) buttons, chips, checkbox and radio rows, tabs, menu items, palette rows, the filter panel's toggle, code copy buttons, content nav links and fields grow to 44px. Text links in running text stay as they are (2.5.8 exempts inline targets).
1287
1343
  - **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.
1344
+ - **Motion follows the browser.** Under `prefers-reduced-motion: reduce` every transition and animation finishes in 0.01ms (since 0.12.0); `data-motion="essential"` is the one way out. `useMotionAllowed` covers motion JavaScript drives.
1345
+ - **High contrast.** Under `prefers-contrast: more` the text steps lighten, `h2` darkens and shapes that rely on a background shade get a `c4` edge (since 0.12.0). Forced colors (Windows high contrast) keep a 1px border on buttons, cards, chips and badges.
1288
1346
  - **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.
1289
1347
  - **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"`.
1290
1348
  - **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.
@@ -6,7 +6,7 @@
6
6
  * A rejected action shows the failure message in rose.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  import { type ReactNode } from "react";
12
12
  import { type ButtonProps } from "../basics/Button.js";
@@ -6,13 +6,14 @@
6
6
  * A rejected action shows the failure message in rose.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  "use client";
12
12
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
13
13
  import { useRef, useState } from "react";
14
14
  import { cx } from "../../utils/cx.js";
15
15
  import { Button } from "../basics/Button.js";
16
+ import { TEXT_TONES } from "../basics/textTones.js";
16
17
  import { StatusOutput } from "./StatusOutput.js";
17
18
  import { useLatestStatus } from "./useLatestStatus.js";
18
19
  /**
@@ -44,5 +45,5 @@ export function AsyncButton({ action, pendingLabel, failedMessage = "Something w
44
45
  setPending(false);
45
46
  }
46
47
  };
47
- return (_jsxs("div", { className: cx("flex flex-wrap items-center gap-3", wrapperClassName), children: [_jsx(Button, { "aria-disabled": pending || undefined, onClick: run, className: cx("aria-disabled:cursor-wait aria-disabled:opacity-70", className), ...props, children: pending && pendingLabel !== undefined ? pendingLabel : children }), _jsx(StatusOutput, { run: status?.run ?? null, children: status?.result.failed ? (_jsx("span", { className: "text-rose-300", children: status.result.message })) : (status?.result.message) })] }));
48
+ return (_jsxs("div", { className: cx("flex flex-wrap items-center gap-3", wrapperClassName), children: [_jsx(Button, { "aria-disabled": pending || undefined, onClick: run, className: cx("aria-disabled:cursor-wait aria-disabled:opacity-70", className), ...props, children: pending && pendingLabel !== undefined ? pendingLabel : children }), _jsx(StatusOutput, { run: status?.run ?? null, children: status?.result.failed ? (_jsx("span", { className: TEXT_TONES.error, children: status.result.message })) : (status?.result.message) })] }));
48
49
  }
@@ -2,11 +2,12 @@
2
2
  * @file src/components/basics/AutoLink.tsx
3
3
  * @desc A link that picks its element from the href (internal): `next/link` for paths inside the
4
4
  * app, a plain `<a>` for a string href that leaves it (a scheme like https: or mailto:, or
5
- * //host). The plain `<a>` drops next/link's own props, and gets rel="noreferrer" when it
6
- * opens in a new tab. ButtonLink, TextLink, the nav and the footer all render it.
5
+ * //host) or that is a download (`download` set, even to `""`): no prefetch and no client
6
+ * routing for a file. The plain `<a>` drops next/link's own props, and gets rel="noreferrer"
7
+ * when it opens in a new tab. ButtonLink, TextLink, the nav and the footer all render it.
7
8
  * @author David @dvhsh (https://dvh.sh)
8
9
  * @created Wed Sep 23, 2026
9
- * @modified Mon Sep 28, 2026
10
+ * @modified Sun Oct 4, 2026
10
11
  */
11
12
  import Link from "next/link.js";
12
13
  import type { ComponentProps } from "react";
@@ -16,6 +17,9 @@ export type AutoLinkProps = ComponentProps<typeof Link>;
16
17
  * @function AutoLink
17
18
  * @param props {AutoLinkProps} next/link props
18
19
  * @returns {JSX.Element} a `next/link` for internal paths (and URL objects), or a plain `<a>` for
19
- * an external string href, with rel="noreferrer" when `target="_blank"` and no `rel`
20
+ * an external string href or a download, with rel="noreferrer" when `target="_blank"`
21
+ * and no `rel`. `download` only forces the plain `<a>` for a string `href`: a URL-object
22
+ * href still renders `next/link` (next/link's own typed routes don't take `download`
23
+ * either, so that pairing isn't one this component needs to special-case).
20
24
  */
21
25
  export declare function AutoLink({ href, target, rel, ...props }: AutoLinkProps): import("react").JSX.Element;
@@ -3,11 +3,12 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  * @file src/components/basics/AutoLink.tsx
4
4
  * @desc A link that picks its element from the href (internal): `next/link` for paths inside the
5
5
  * app, a plain `<a>` for a string href that leaves it (a scheme like https: or mailto:, or
6
- * //host). The plain `<a>` drops next/link's own props, and gets rel="noreferrer" when it
7
- * opens in a new tab. ButtonLink, TextLink, the nav and the footer all render it.
6
+ * //host) or that is a download (`download` set, even to `""`): no prefetch and no client
7
+ * routing for a file. The plain `<a>` drops next/link's own props, and gets rel="noreferrer"
8
+ * when it opens in a new tab. ButtonLink, TextLink, the nav and the footer all render it.
8
9
  * @author David @dvhsh (https://dvh.sh)
9
10
  * @created Wed Sep 23, 2026
10
- * @modified Mon Sep 28, 2026
11
+ * @modified Sun Oct 4, 2026
11
12
  */
12
13
  import Link from "next/link.js";
13
14
  import { isExternalHref } from "../../utils/href.js";
@@ -15,10 +16,16 @@ import { isExternalHref } from "../../utils/href.js";
15
16
  * @function AutoLink
16
17
  * @param props {AutoLinkProps} next/link props
17
18
  * @returns {JSX.Element} a `next/link` for internal paths (and URL objects), or a plain `<a>` for
18
- * an external string href, with rel="noreferrer" when `target="_blank"` and no `rel`
19
+ * an external string href or a download, with rel="noreferrer" when `target="_blank"`
20
+ * and no `rel`. `download` only forces the plain `<a>` for a string `href`: a URL-object
21
+ * href still renders `next/link` (next/link's own typed routes don't take `download`
22
+ * either, so that pairing isn't one this component needs to special-case).
19
23
  */
20
24
  export function AutoLink({ href, target, rel, ...props }) {
21
- if (typeof href === "string" && isExternalHref(href)) {
25
+ // `download` counts when set to anything but undefined or false: "" is the attribute's own
26
+ // boolean form. A download is a file, never a page to prefetch or route to.
27
+ const download = props.download !== undefined && props.download !== false;
28
+ if (typeof href === "string" && (isExternalHref(href) || download)) {
22
29
  const { as: _as, replace: _replace, scroll: _scroll, shallow: _shallow, passHref: _passHref, prefetch: _prefetch, locale: _locale, legacyBehavior: _legacyBehavior, onNavigate: _onNavigate, transitionTypes: _transitionTypes, ...anchorProps } = props;
23
30
  return (_jsx("a", { href: href, target: target, rel: rel ?? (target === "_blank" ? "noreferrer" : undefined), ...anchorProps }));
24
31
  }
@@ -4,7 +4,7 @@
4
4
  * so screen readers read it in place with the text around it. Server-safe.
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Mon Sep 28, 2026
7
- * @modified Mon Sep 28, 2026
7
+ * @modified Sun Oct 4, 2026
8
8
  */
9
9
  import type { ComponentProps } from "react";
10
10
  /** The badge's colors: neutral b3, accent h1, a warning amber, or a muted outline. */
@@ -1,12 +1,12 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from "../../utils/cx.js";
3
- const BASE = "inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs";
3
+ const BASE = "inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 text-xs forced-colors:border";
4
4
  // The looks the apps already use: pools' tags and warnings, and its header's beta pill.
5
5
  const TONES = {
6
- neutral: "bg-b3 text-c2",
6
+ neutral: "bg-b3 text-c2 contrast-more:inset-ring contrast-more:inset-ring-c4",
7
7
  accent: "bg-h1 font-bold text-b6",
8
8
  warning: "bg-amber-300/20 font-bold text-amber-200",
9
- muted: "border border-b3 bg-b5 font-bold text-c4 uppercase tracking-wide",
9
+ muted: "border border-b3 bg-b5 font-bold text-c4 uppercase tracking-wide contrast-more:border-c4",
10
10
  };
11
11
  /**
12
12
  * @function Badge
@@ -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 Sat Oct 3, 2026
8
+ * @modified Sun Oct 4, 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 Sat Oct 3, 2026
8
+ * @modified Sun Oct 4, 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 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 })] }));
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 coarse:min-h-11 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
  * status region that mounts with its text already inside may not be announced.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Wed Sep 23, 2026
9
- * @modified Mon Sep 28, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  import type { ComponentProps } from "react";
12
12
  /** The notice's color, and its live role: `info` is c3, `warning` amber, `error` rose. */
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { cx } from "../../utils/cx.js";
3
- const TONES = {
4
- info: "text-c3",
5
- warning: "text-amber-300",
6
- error: "text-rose-300",
2
+ import { textClasses } from "./textStyles.js";
3
+ const NOTICE_TONES = {
4
+ info: "muted",
5
+ warning: "warning",
6
+ error: "error",
7
7
  };
8
8
  /**
9
9
  * @function Notice
@@ -13,7 +13,7 @@ const TONES = {
13
13
  */
14
14
  export function Notice({ tone = "info", live = false, as = "p", className, ...props }) {
15
15
  const role = live ? (tone === "error" ? "alert" : "status") : undefined;
16
- const classes = cx("text-sm", TONES[tone], className);
16
+ const classes = textClasses({ tone: NOTICE_TONES[tone], className });
17
17
  if (as === "div") {
18
18
  // Same attributes either way; only the ref's element type differs.
19
19
  return _jsx("div", { role: role, className: classes, ...props });
@@ -7,7 +7,7 @@
7
7
  * `aria-labelledby={tabId(idPrefix, tab)}`. Controlled. Moved from bb.haruhime.moe.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Mon Sep 28, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sun Oct 4, 2026
11
11
  */
12
12
  import type { ComponentProps, ReactNode } from "react";
13
13
  /** One tab: its id (part of the tab's and panel's ids) and its text. */
@@ -7,7 +7,7 @@
7
7
  * `aria-labelledby={tabId(idPrefix, tab)}`. Controlled. Moved from bb.haruhime.moe.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Mon Sep 28, 2026
10
- * @modified Mon Sep 28, 2026
10
+ * @modified Sun Oct 4, 2026
11
11
  */
12
12
  "use client";
13
13
  import { jsx as _jsx } from "react/jsx-runtime";
@@ -40,8 +40,8 @@ export function Tabs({ label, idPrefix, tabs, value, onChange, className, onKeyD
40
40
  onChange(tab.id);
41
41
  document.getElementById(tabId(idPrefix, tab.id))?.focus();
42
42
  };
43
- return (_jsx("div", { role: "tablist", "aria-label": label, onKeyDown: move, className: cx("flex gap-1 rounded-full bg-b4 p-1", className), ...props, children: tabs.map((tab, index) => {
43
+ return (_jsx("div", { role: "tablist", "aria-label": label, onKeyDown: move, className: cx("flex gap-1 rounded-full bg-b4 p-1 contrast-more:inset-ring contrast-more:inset-ring-c4 forced-colors:border", className), ...props, children: tabs.map((tab, index) => {
44
44
  const selected = tab.id === value;
45
- return (_jsx("button", { type: "button", role: "tab", id: tabId(idPrefix, tab.id), "aria-selected": selected, "aria-controls": tabPanelId(idPrefix, tab.id), tabIndex: index === at ? 0 : -1, onClick: () => onChange(tab.id), className: cx("flex-1 rounded-full px-4 py-1.5 font-bold text-sm transition-colors", selected ? "bg-h2 text-c1 forced-colors:underline" : "text-c3 hover:text-c1"), children: tab.label }, tab.id));
45
+ return (_jsx("button", { type: "button", role: "tab", id: tabId(idPrefix, tab.id), "aria-selected": selected, "aria-controls": tabPanelId(idPrefix, tab.id), tabIndex: index === at ? 0 : -1, onClick: () => onChange(tab.id), className: cx("coarse:min-h-11 flex-1 rounded-full px-4 py-1.5 font-bold text-sm transition-colors", selected ? "bg-h2 text-c1 forced-colors:underline" : "text-c3 hover:text-c1"), children: tab.label }, tab.id));
46
46
  }) }));
47
47
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @file src/components/basics/Text.tsx
3
+ * @desc A line of text in one of six tones (body, muted, subtle, error, warning, success) and
4
+ * three sizes. Styling only: not a live region. A message that appears after an action
5
+ * still goes in Notice live, StatusOutput or a mounted role="status". Server-safe.
6
+ * @author David @dvhsh (https://dvh.sh)
7
+ * @created Sun Oct 4, 2026
8
+ * @modified Sun Oct 4, 2026
9
+ */
10
+ import type { ComponentProps } from "react";
11
+ import { type TextSize, type TextTone } from "./textStyles.js";
12
+ /** Every native `<p>` prop (including `ref`), plus the element, tone, size and weight. */
13
+ export type TextProps = ComponentProps<"p"> & {
14
+ /** The element: "p" (default), "span" or "div". */
15
+ as?: "p" | "span" | "div" | undefined;
16
+ tone?: TextTone | undefined;
17
+ size?: TextSize | undefined;
18
+ bold?: boolean | undefined;
19
+ };
20
+ /**
21
+ * @function Text
22
+ * @param props {TextProps} native paragraph props, plus as, tone (default "default"), size
23
+ * (default "sm") and bold
24
+ * @returns {JSX.Element} a `<p>`, `<span>` or `<div>` in the tone's color and the size
25
+ */
26
+ export declare function Text({ as, tone, size, bold, className, ...props }: TextProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { textClasses } from "./textStyles.js";
3
+ /**
4
+ * @function Text
5
+ * @param props {TextProps} native paragraph props, plus as, tone (default "default"), size
6
+ * (default "sm") and bold
7
+ * @returns {JSX.Element} a `<p>`, `<span>` or `<div>` in the tone's color and the size
8
+ */
9
+ export function Text({ as = "p", tone, size, bold, className, ...props }) {
10
+ const classes = textClasses({ tone, size, bold, className });
11
+ // Same attributes for every element; only the ref's element type differs.
12
+ if (as === "span")
13
+ return _jsx("span", { className: classes, ...props });
14
+ if (as === "div")
15
+ return _jsx("div", { className: classes, ...props });
16
+ return _jsx("p", { className: classes, ...props });
17
+ }
@@ -3,11 +3,11 @@
3
3
  * @desc Shared class builder for Button and ButtonLink (osu!-web pill buttons).
4
4
  * @author David @dvhsh (https://dvh.sh)
5
5
  * @created Wed Sep 23, 2026
6
- * @modified Mon Sep 28, 2026
6
+ * @modified Sun Oct 4, 2026
7
7
  */
8
8
  /** The pill's colors: `primary` is h2 (h1 on hover), `secondary` b3, `ghost` transparent. */
9
9
  export type ButtonVariant = "primary" | "secondary" | "ghost";
10
- /** The pill's height, padding and text size: `md` is h-9 and text-sm, `lg` h-11 and text-base. */
10
+ /** The pill's height, padding and text size: `md` is h-9 (h-11 on a coarse pointer) and text-sm, `lg` h-11 and text-base. */
11
11
  export type ButtonSize = "md" | "lg";
12
12
  /** Options for {@link buttonClasses}. */
13
13
  export type ButtonClassOptions = {
@@ -3,19 +3,22 @@
3
3
  * @desc Shared class builder for Button and ButtonLink (osu!-web pill buttons).
4
4
  * @author David @dvhsh (https://dvh.sh)
5
5
  * @created Wed Sep 23, 2026
6
- * @modified Mon Sep 28, 2026
6
+ * @modified Sun Oct 4, 2026
7
7
  */
8
8
  import { cx } from "../../utils/cx.js";
9
- const BASE = "inline-flex items-center justify-center gap-2 rounded-full font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-h1 disabled:cursor-not-allowed disabled:opacity-50";
9
+ // w-fit: a flex-column or grid item keeps its content width instead of stretching. The 1px
10
+ // border shows only in forced colors, where the pill's background is dropped.
11
+ const BASE = "inline-flex w-fit items-center justify-center gap-2 rounded-full font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-h1 disabled:cursor-not-allowed disabled:opacity-50 forced-colors:border forced-colors:disabled:text-[GrayText]";
10
12
  // Hover only when not disabled. `not-disabled:` (not `enabled:`) so links, which are never
11
- // :disabled, keep their hover colors.
13
+ // :disabled, keep their hover colors. Under more contrast, b3 and a transparent pill get a c4
14
+ // edge: b3 on b5 is under 1.5:1.
12
15
  const VARIANTS = {
13
16
  primary: "bg-h2 text-c1 not-disabled:hover:bg-h1 not-disabled:hover:text-b6",
14
- secondary: "bg-b3 text-c1 not-disabled:hover:bg-b2",
15
- ghost: "bg-transparent text-c2 not-disabled:hover:bg-b4 not-disabled:hover:text-c1",
17
+ secondary: "bg-b3 text-c1 not-disabled:hover:bg-b2 contrast-more:inset-ring contrast-more:inset-ring-c4",
18
+ ghost: "bg-transparent text-c2 not-disabled:hover:bg-b4 not-disabled:hover:text-c1 contrast-more:inset-ring contrast-more:inset-ring-c4",
16
19
  };
17
20
  const SIZES = {
18
- md: "h-9 px-4 text-sm",
21
+ md: "h-9 px-4 text-sm coarse:h-11",
19
22
  lg: "h-11 px-6 text-base",
20
23
  };
21
24
  /**
@@ -4,11 +4,12 @@
4
4
  * title, plus the heading levels either one takes for that title.
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Mon Sep 28, 2026
7
- * @modified Mon Sep 28, 2026
7
+ * @modified Sun Oct 4, 2026
8
8
  */
9
9
  /** A heading level for a component's title: `h2` to `h6` (a page has one `h1`). */
10
10
  export type HeadingLevel = 2 | 3 | 4 | 5 | 6;
11
- /** The panel: rounded, b4 background, p-5, c2 text. */
12
- export declare const CARD = "rounded-[10px] bg-b4 p-5 text-c2";
11
+ /** The panel: rounded, b4 background, p-5, c2 text; a c4 edge under more contrast, a 1px border
12
+ * in forced colors. */
13
+ export declare const CARD = "rounded-[10px] bg-b4 p-5 text-c2 contrast-more:inset-ring contrast-more:inset-ring-c4 forced-colors:border";
13
14
  /** The panel's title: bold c1 at text-lg. */
14
15
  export declare const CARD_HEADING = "font-bold text-c1 text-lg";
@@ -4,9 +4,10 @@
4
4
  * title, plus the heading levels either one takes for that title.
5
5
  * @author David @dvhsh (https://dvh.sh)
6
6
  * @created Mon Sep 28, 2026
7
- * @modified Mon Sep 28, 2026
7
+ * @modified Sun Oct 4, 2026
8
8
  */
9
- /** The panel: rounded, b4 background, p-5, c2 text. */
10
- export const CARD = "rounded-[10px] bg-b4 p-5 text-c2";
9
+ /** The panel: rounded, b4 background, p-5, c2 text; a c4 edge under more contrast, a 1px border
10
+ * in forced colors. */
11
+ export const CARD = "rounded-[10px] bg-b4 p-5 text-c2 contrast-more:inset-ring contrast-more:inset-ring-c4 forced-colors:border";
11
12
  /** The panel's title: bold c1 at text-lg. */
12
13
  export const CARD_HEADING = "font-bold text-c1 text-lg";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file src/components/basics/textStyles.ts
3
+ * @desc textClasses: Text's look as a class string, for elements Text can't be (an <output>, a
4
+ * <time>, a <li>, a class map). Server-safe.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ import { type TextSize, type TextTone } from "./textTones.js";
10
+ export type { TextSize, TextTone } from "./textTones.js";
11
+ /** Options for {@link textClasses}. */
12
+ export type TextClassOptions = {
13
+ /** "default" (c2), "muted" (c3), "subtle" (c4), "error", "warning" or "success". */
14
+ tone?: TextTone | undefined;
15
+ /** "xs", "sm" (default) or "base". */
16
+ size?: TextSize | undefined;
17
+ /** font-bold. */
18
+ bold?: boolean | undefined;
19
+ className?: string | undefined;
20
+ };
21
+ /**
22
+ * @function textClasses
23
+ * @param opts {TextClassOptions} tone (default "default"), size (default "sm"), bold and extra
24
+ * classes
25
+ * @returns {string} the text classes, with the caller's className last (it replaces a built-in
26
+ * class that sets the same property)
27
+ */
28
+ export declare const textClasses: ({ tone, size, bold, className, }?: TextClassOptions) => string;