@haruhimemoe/ui 0.11.1 → 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 (76) hide show
  1. package/CHANGELOG.md +36 -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.d.ts +3 -1
  30. package/dist/components/content/CopyMarkdownButton.js +23 -8
  31. package/dist/components/filters/FilterPanel.d.ts +1 -1
  32. package/dist/components/filters/FilterPanel.js +2 -2
  33. package/dist/components/filters/GroupFrame.d.ts +4 -2
  34. package/dist/components/filters/GroupFrame.js +1 -1
  35. package/dist/components/filters/chipStyles.d.ts +7 -6
  36. package/dist/components/filters/chipStyles.js +8 -6
  37. package/dist/components/forms/CharCounter.d.ts +1 -1
  38. package/dist/components/forms/CharCounter.js +6 -1
  39. package/dist/components/forms/Checkbox.d.ts +4 -3
  40. package/dist/components/forms/Checkbox.js +5 -4
  41. package/dist/components/forms/FieldFrame.d.ts +9 -4
  42. package/dist/components/forms/FieldFrame.js +6 -5
  43. package/dist/components/forms/RadioGroup.d.ts +4 -2
  44. package/dist/components/forms/RadioGroup.js +4 -4
  45. package/dist/components/forms/ReportDisclosure.d.ts +1 -1
  46. package/dist/components/forms/ReportDisclosure.js +2 -2
  47. package/dist/components/forms/Select.d.ts +4 -4
  48. package/dist/components/forms/Select.js +4 -4
  49. package/dist/components/forms/TextInput.d.ts +3 -3
  50. package/dist/components/forms/TextInput.js +3 -3
  51. package/dist/components/forms/Textarea.d.ts +6 -4
  52. package/dist/components/forms/Textarea.js +3 -3
  53. package/dist/components/forms/TypeToConfirm.d.ts +1 -1
  54. package/dist/components/forms/TypeToConfirm.js +2 -2
  55. package/dist/components/forms/fieldStyles.d.ts +5 -3
  56. package/dist/components/forms/fieldStyles.js +6 -4
  57. package/dist/components/mdx/CodeBlock.d.ts +1 -1
  58. package/dist/components/mdx/CodeBlock.js +1 -1
  59. package/dist/components/mdx/CodeCopyButton.d.ts +1 -1
  60. package/dist/components/mdx/CodeCopyButton.js +2 -2
  61. package/dist/components/osu/ModBadge.d.ts +14 -9
  62. package/dist/components/osu/ModBadge.js +42 -14
  63. package/dist/components/palette/CommandPalette.d.ts +1 -1
  64. package/dist/components/palette/CommandPalette.js +2 -2
  65. package/dist/components/palette/PaletteInput.d.ts +1 -1
  66. package/dist/components/palette/PaletteInput.js +3 -2
  67. package/dist/components/palette/PaletteRow.d.ts +1 -1
  68. package/dist/components/palette/PaletteRow.js +3 -3
  69. package/dist/components/shell/HeaderMenu.d.ts +1 -1
  70. package/dist/components/shell/HeaderMenu.js +3 -3
  71. package/dist/components/shell/LinkTabs.d.ts +1 -1
  72. package/dist/components/shell/LinkTabs.js +1 -1
  73. package/dist/index.d.ts +4 -1
  74. package/dist/index.js +4 -1
  75. package/dist/theme.css +56 -6
  76. package/package.json +1 -1
@@ -3,10 +3,10 @@ import { FieldFrame, fieldControlProps } from "./FieldFrame.js";
3
3
  import { fieldClasses } from "./fieldStyles.js";
4
4
  /**
5
5
  * @function TextInput
6
- * @param props {TextInputProps} native input props, plus id, label, hint, error and
6
+ * @param props {TextInputProps} native input props, plus id, label, hideLabel, hint, error and
7
7
  * wrapperClassName; `className` goes on the `<input>`
8
8
  * @returns {JSX.Element} a label, the input, and the hint and error when given
9
9
  */
10
- export function TextInput({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
11
- return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("input", { ...props, ...fieldControlProps({ id, hint, error, describedBy, invalid }), className: fieldClasses(className) }) }));
10
+ export function TextInput({ id, label, hideLabel, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
11
+ return (_jsx(FieldFrame, { id: id, label: label, hideLabel: hideLabel, hint: hint, error: error, className: wrapperClassName, children: _jsx("input", { ...props, ...fieldControlProps({ id, hint, error, describedBy, invalid }), className: fieldClasses(className) }) }));
12
12
  }
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * @file src/components/forms/Textarea.tsx
3
3
  * @desc Labeled textarea on the shared field look (at least 6rem tall, resizes vertically), with
4
- * an optional hint and error wired through aria-describedby and aria-invalid. Server-safe.
4
+ * an optional hint and error wired through aria-describedby and aria-invalid. Repeats
5
+ * `coarse:min-h-24` so tailwind-merge drops the field look's `coarse:min-h-11`: a 44px
6
+ * minimum would shrink the textarea on a touch screen. Server-safe.
5
7
  * @author David @dvhsh (https://dvh.sh)
6
8
  * @created Wed Sep 23, 2026
7
- * @modified Mon Sep 28, 2026
9
+ * @modified Sun Oct 4, 2026
8
10
  */
9
11
  import type { ComponentProps } from "react";
10
12
  import { type FieldProps } from "./FieldFrame.js";
@@ -12,8 +14,8 @@ import { type FieldProps } from "./FieldFrame.js";
12
14
  export type TextareaProps = Omit<ComponentProps<"textarea">, "id"> & FieldProps;
13
15
  /**
14
16
  * @function Textarea
15
- * @param props {TextareaProps} native textarea props, plus id, label, hint, error and
17
+ * @param props {TextareaProps} native textarea props, plus id, label, hideLabel, hint, error and
16
18
  * wrapperClassName; `className` goes on the `<textarea>`
17
19
  * @returns {JSX.Element} a label, the textarea, and the hint and error when given
18
20
  */
19
- export declare function Textarea({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }: TextareaProps): import("react").JSX.Element;
21
+ export declare function Textarea({ id, label, hideLabel, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }: TextareaProps): import("react").JSX.Element;
@@ -4,10 +4,10 @@ import { FieldFrame, fieldControlProps } from "./FieldFrame.js";
4
4
  import { fieldClasses } from "./fieldStyles.js";
5
5
  /**
6
6
  * @function Textarea
7
- * @param props {TextareaProps} native textarea props, plus id, label, hint, error and
7
+ * @param props {TextareaProps} native textarea props, plus id, label, hideLabel, hint, error and
8
8
  * wrapperClassName; `className` goes on the `<textarea>`
9
9
  * @returns {JSX.Element} a label, the textarea, and the hint and error when given
10
10
  */
11
- export function Textarea({ id, label, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
12
- return (_jsx(FieldFrame, { id: id, label: label, hint: hint, error: error, className: wrapperClassName, children: _jsx("textarea", { ...props, ...fieldControlProps({ id, hint, error, describedBy, invalid }), className: fieldClasses(cx("min-h-24 resize-y", className)) }) }));
11
+ export function Textarea({ id, label, hideLabel, hint, error, wrapperClassName, className, "aria-describedby": describedBy, "aria-invalid": invalid, ...props }) {
12
+ return (_jsx(FieldFrame, { id: id, label: label, hideLabel: hideLabel, hint: hint, error: error, className: wrapperClassName, children: _jsx("textarea", { ...props, ...fieldControlProps({ id, hint, error, describedBy, invalid }), className: fieldClasses(cx("coarse:min-h-24 min-h-24 resize-y", className)) }) }));
13
13
  }
@@ -6,7 +6,7 @@
6
6
  * delete and transfer forms, as one component.
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 ComponentProps, type ReactNode } from "react";
12
12
  import type { ButtonVariant } from "../basics/buttonStyles.js";
@@ -6,7 +6,7 @@
6
6
  * delete and transfer forms, as one component.
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";
@@ -42,5 +42,5 @@ export function TypeToConfirm({ id, expected, label = `Type ${expected} to confi
42
42
  setPending(false);
43
43
  }
44
44
  };
45
- return (_jsxs("form", { onSubmit: submit, className: cx("flex flex-col gap-3", className), ...props, children: [children, _jsx(TextInput, { id: id, label: label, hint: hint, error: error, value: typed, autoComplete: "off", autoCapitalize: "off", spellCheck: false, onChange: (event) => setTyped(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: variant, className: "self-start", disabled: !matches || pending, children: pending && pendingLabel !== undefined ? pendingLabel : submitLabel })] }));
45
+ return (_jsxs("form", { onSubmit: submit, className: cx("flex flex-col gap-3", className), ...props, children: [children, _jsx(TextInput, { id: id, label: label, hint: hint, error: error, value: typed, autoComplete: "off", autoCapitalize: "off", spellCheck: false, onChange: (event) => setTyped(event.currentTarget.value) }), _jsx(Button, { type: "submit", variant: variant, disabled: !matches || pending, children: pending && pendingLabel !== undefined ? pendingLabel : submitLabel })] }));
46
46
  }
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * @file src/components/forms/fieldStyles.ts
3
- * * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
3
+ * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
4
4
  * the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
5
5
  * every control gets, plus an h1 border; an invalid field keeps its rose border until it
6
- * takes focus, when the h1 border and ring take over so focus stays visible.
6
+ * takes focus, when the h1 border and ring take over so focus stays visible. On a coarse
7
+ * pointer a field is 44px tall with 16px text (iOS Safari zooms into smaller text); under
8
+ * more contrast its border is c4.
7
9
  * @author David @dvhsh (https://dvh.sh)
8
10
  * @created Wed Sep 23, 2026
9
- * @modified Sat Oct 3, 2026
11
+ * @modified Sun Oct 4, 2026
10
12
  */
11
13
  /** A field or group label: bold c3 at text-sm (internal). */
12
14
  export declare const FIELD_LABEL = "font-bold text-c3 text-sm";
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * @file src/components/forms/fieldStyles.ts
3
- * * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
3
+ * @desc Shared field classes for TextInput, Select, Textarea and RangeBox (the packs look), and
4
4
  * the label class the fieldsets share. Focus is the theme's 2px h1 outline, the same ring
5
5
  * every control gets, plus an h1 border; an invalid field keeps its rose border until it
6
- * takes focus, when the h1 border and ring take over so focus stays visible.
6
+ * takes focus, when the h1 border and ring take over so focus stays visible. On a coarse
7
+ * pointer a field is 44px tall with 16px text (iOS Safari zooms into smaller text); under
8
+ * more contrast its border is c4.
7
9
  * @author David @dvhsh (https://dvh.sh)
8
10
  * @created Wed Sep 23, 2026
9
- * @modified Sat Oct 3, 2026
11
+ * @modified Sun Oct 4, 2026
10
12
  */
11
13
  import { cx } from "../../utils/cx.js";
12
- const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1";
14
+ const FIELD = "w-full rounded-md border border-b3 bg-b6 px-3 py-2 text-c1 text-sm placeholder:text-c4 focus-visible:border-h1 disabled:opacity-50 aria-invalid:border-rose-400 aria-invalid:focus-visible:border-h1 coarse:min-h-11 coarse:text-base contrast-more:border-c4";
13
15
  /** A field or group label: bold c3 at text-sm (internal). */
14
16
  export const FIELD_LABEL = "font-bold text-c3 text-sm";
15
17
  /**
@@ -6,7 +6,7 @@
6
6
  * Server-safe: the only client piece is the copy button (CodeCopyButton).
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Sat Oct 3, 2026
9
- * @modified Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  import type { ComponentProps } from "react";
12
12
  /** The code (any line endings, trailing newline optional), language, title, highlighted lines
@@ -37,7 +37,7 @@ export async function CodeBlock({ code, lang, title, highlight = [], className,
37
37
  const lines = tokens ?? text.split("\n").map((content) => [{ content, offset: 0 }]);
38
38
  const marked = new Set(highlight);
39
39
  const name = title ?? lang;
40
- return (_jsxs("div", { className: cx("mt-3 overflow-hidden rounded-md border border-b3 bg-b6", className), ...rest, children: [_jsxs("div", { className: "flex min-h-9 items-center justify-between gap-3 border-b3 border-b px-3 py-1 text-c3 text-sm", children: [_jsx("span", { className: "truncate", children: name ?? "Code" }), _jsx(CodeCopyButton, { code: text, label: title ? `Copy ${title}` : "Copy code" })] }), _jsx("pre", { role: "group",
40
+ return (_jsxs("div", { className: cx("mt-3 overflow-hidden rounded-md border border-b3 bg-b6 contrast-more:border-c4", className), ...rest, children: [_jsxs("div", { className: "flex min-h-9 items-center justify-between gap-3 border-b3 border-b px-3 py-1 text-c3 text-sm", children: [_jsx("span", { className: "truncate", children: name ?? "Code" }), _jsx(CodeCopyButton, { code: text, label: title ? `Copy ${title}` : "Copy code" })] }), _jsx("pre", { role: "group",
41
41
  // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region needs keyboard focus
42
42
  tabIndex: 0, "aria-label": name ? `Code: ${name}` : "Code", className: "overflow-x-auto py-3 text-c2 text-sm leading-6", children: _jsx("code", { children: lines.flatMap((line, index) => {
43
43
  const span = (_jsx("span", { className: marked.has(index + 1) ? MARKED : LINE, "data-highlighted": marked.has(index + 1) ? "" : undefined, children: line.map((token, i) => tokens ? (_jsx("span", { style: tokenStyle(token), children: token.content }, i)) : (token.content)) }, index));
@@ -6,7 +6,7 @@
6
6
  * string and never imports cx, keeping tailwind-merge out of the client bundle.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Sat Oct 3, 2026
9
- * @modified Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  /** The code to copy, and the button's accessible name (e.g. "Copy x.ts" or "Copy code"). */
12
12
  export type CodeCopyButtonProps = {
@@ -6,13 +6,13 @@
6
6
  * string and never imports cx, keeping tailwind-merge out of the client bundle.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Sat Oct 3, 2026
9
- * @modified Sat Oct 3, 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 { StatusOutput } from "../actions/StatusOutput.js";
14
14
  import { useLatestStatus } from "../actions/useLatestStatus.js";
15
- const BUTTON = "inline-flex min-h-6 min-w-6 items-center justify-center rounded px-2 py-0.5 text-c2 text-sm hover:bg-b4 hover:text-c1";
15
+ const BUTTON = "inline-flex min-h-6 min-w-6 items-center justify-center rounded px-2 py-0.5 text-c2 text-sm hover:bg-b4 hover:text-c1 coarse:min-h-11 coarse:min-w-11";
16
16
  /**
17
17
  * @function CodeCopyButton
18
18
  * @param props {CodeCopyButtonProps} the code to copy and the button's accessible name
@@ -2,22 +2,27 @@
2
2
  * @file src/components/osu/ModBadge.tsx
3
3
  * @desc A mod pool slot's pill (NM1, HD2, TB), colored by its mod bucket the way tournament
4
4
  * sheets and packs.haruhime.moe color them: NM sky, HD amber, HR rose, DT and NC violet,
5
- * FM emerald, TB orange. Anything else is a quiet b3 pill; `className` recolors it (a
6
- * custom bucket). Server-safe.
5
+ * FM emerald, TB orange. Anything else is a quiet b3 pill; `color` picks any of 17 colors
6
+ * (the buckets', the pool palette's and neutral) for a custom bucket. Server-safe.
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 { ComponentProps } from "react";
12
- /** Every native `<span>` prop (including `ref`), plus the slot or mod it shows. */
13
- export type ModBadgeProps = ComponentProps<"span"> & {
12
+ /** A badge color: the six bucket colors, @haruhimemoe/pool's PALETTE names, or neutral. */
13
+ export type ModBadgeColor = "sky" | "amber" | "rose" | "violet" | "emerald" | "orange" | "green" | "teal" | "pink" | "lime" | "cyan" | "fuchsia" | "yellow" | "red" | "indigo" | "stone" | "neutral";
14
+ /** Every native `<span>` prop (except the legacy `color`), plus the slot and an optional color. */
15
+ export type ModBadgeProps = Omit<ComponentProps<"span">, "color"> & {
14
16
  /** A mod or slot label ("HD", "NM1", "TB"). Its first two letters pick the color. */
15
17
  mod: string;
18
+ /** Overrides the color picked from the mod's first two letters. */
19
+ color?: ModBadgeColor | undefined;
16
20
  };
17
21
  /**
18
22
  * @function ModBadge
19
- * @param props {ModBadgeProps} the mod or slot label, and native span props (children replace
20
- * the shown text; a `title` can name the bucket)
21
- * @returns {JSX.Element} a rounded pill in the bucket's color with dark text, or b3 with c2 text
23
+ * @param props {ModBadgeProps} the mod or slot label, an optional color, and native span props
24
+ * (children replace the shown text; a `title` can name the bucket)
25
+ * @returns {JSX.Element} a rounded pill in the color (or the bucket's), dark text on color, c2 on
26
+ * neutral
22
27
  */
23
- export declare function ModBadge({ mod, className, children, ...props }: ModBadgeProps): import("react").JSX.Element;
28
+ export declare function ModBadge({ mod, color, className, children, ...props }: ModBadgeProps): import("react").JSX.Element;
@@ -1,22 +1,50 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from "../../utils/cx.js";
3
+ // Literal classes, so the app's Tailwind finds them through theme.css's @source. Every background
4
+ // is light enough for text-b6 at 4.5:1 or more.
5
+ const COLORS = {
6
+ sky: "bg-sky-400 text-b6",
7
+ amber: "bg-amber-300 text-b6",
8
+ rose: "bg-rose-400 text-b6",
9
+ violet: "bg-violet-400 text-b6",
10
+ emerald: "bg-emerald-400 text-b6",
11
+ orange: "bg-orange-400 text-b6",
12
+ green: "bg-green-400 text-b6",
13
+ teal: "bg-teal-300 text-b6",
14
+ pink: "bg-pink-400 text-b6",
15
+ lime: "bg-lime-300 text-b6",
16
+ cyan: "bg-cyan-300 text-b6",
17
+ fuchsia: "bg-fuchsia-400 text-b6",
18
+ yellow: "bg-yellow-300 text-b6",
19
+ red: "bg-red-400 text-b6",
20
+ indigo: "bg-indigo-300 text-b6",
21
+ stone: "bg-stone-300 text-b6",
22
+ neutral: "bg-b3 text-c2",
23
+ };
3
24
  const BUCKETS = {
4
- NM: "bg-sky-400",
5
- HD: "bg-amber-300",
6
- HR: "bg-rose-400",
7
- DT: "bg-violet-400",
8
- NC: "bg-violet-400",
9
- FM: "bg-emerald-400",
10
- TB: "bg-orange-400",
25
+ NM: "sky",
26
+ HD: "amber",
27
+ HR: "rose",
28
+ DT: "violet",
29
+ NC: "violet",
30
+ FM: "emerald",
31
+ TB: "orange",
32
+ };
33
+ const BASE = "inline-flex min-w-11 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 font-extrabold text-xs forced-colors:border";
34
+ /** The color's classes: `color` when it is one, else the mod's bucket, else neutral. */
35
+ const colorClasses = (color, mod) => {
36
+ if (color !== undefined && Object.hasOwn(COLORS, color))
37
+ return COLORS[color];
38
+ const bucket = BUCKETS[mod.slice(0, 2).toUpperCase()];
39
+ return COLORS[bucket ?? "neutral"];
11
40
  };
12
- const BASE = "inline-flex min-w-11 shrink-0 items-center justify-center whitespace-nowrap rounded-full px-2 py-0.5 font-extrabold text-xs";
13
41
  /**
14
42
  * @function ModBadge
15
- * @param props {ModBadgeProps} the mod or slot label, and native span props (children replace
16
- * the shown text; a `title` can name the bucket)
17
- * @returns {JSX.Element} a rounded pill in the bucket's color with dark text, or b3 with c2 text
43
+ * @param props {ModBadgeProps} the mod or slot label, an optional color, and native span props
44
+ * (children replace the shown text; a `title` can name the bucket)
45
+ * @returns {JSX.Element} a rounded pill in the color (or the bucket's), dark text on color, c2 on
46
+ * neutral
18
47
  */
19
- export function ModBadge({ mod, className, children, ...props }) {
20
- const color = BUCKETS[mod.slice(0, 2).toUpperCase()];
21
- return (_jsx("span", { className: cx(BASE, color ? `${color} text-b6` : "bg-b3 text-c2", className), ...props, children: children ?? mod }));
48
+ export function ModBadge({ mod, color, className, children, ...props }) {
49
+ return (_jsx("span", { className: cx(BASE, colorClasses(color, mod), className), ...props, children: children ?? mod }));
22
50
  }
@@ -8,7 +8,7 @@
8
8
  * Mount once, inside a client component, since commands carry functions.
9
9
  * @author David @dvhsh (https://dvh.sh)
10
10
  * @created Sat Oct 3, 2026
11
- * @modified Sat Oct 3, 2026
11
+ * @modified Sun Oct 4, 2026
12
12
  */
13
13
  import type { CommandPaletteProps } from "./types.js";
14
14
  export type { CommandPaletteProps } from "./types.js";
@@ -8,7 +8,7 @@
8
8
  * Mount once, inside a client component, since commands carry functions.
9
9
  * @author David @dvhsh (https://dvh.sh)
10
10
  * @created Sat Oct 3, 2026
11
- * @modified Sat Oct 3, 2026
11
+ * @modified Sun Oct 4, 2026
12
12
  */
13
13
  "use client";
14
14
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -29,7 +29,7 @@ import { useProviderSearch } from "./useProviderSearch.js";
29
29
  const NONE = [];
30
30
  const CHORD_MS = 800;
31
31
  const DIALOG = "m-0 h-dvh max-h-none w-full max-w-none bg-transparent p-0 text-c1 backdrop:bg-b6/70 backdrop:backdrop-blur-sm sm:h-auto sm:pt-[12vh]";
32
- const PANEL = "mx-auto flex h-full max-h-dvh w-full flex-col overflow-hidden border-b3 bg-b6 shadow-2xl sm:h-auto sm:max-h-[70vh] sm:max-w-xl sm:rounded-xl sm:border";
32
+ const PANEL = "mx-auto flex h-full max-h-dvh w-full flex-col overflow-hidden border-b3 bg-b6 shadow-2xl sm:h-auto sm:max-h-[70vh] sm:max-w-xl sm:rounded-xl sm:border contrast-more:border-c4";
33
33
  /**
34
34
  * @function CommandPalette
35
35
  * @param props {CommandPaletteProps} commands, root providers, storage key, hotkey, placeholder,
@@ -7,7 +7,7 @@
7
7
  * always mounted, so a message that appears is announced.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Sat Oct 3, 2026
10
- * @modified Sat Oct 3, 2026
10
+ * @modified Sun Oct 4, 2026
11
11
  */
12
12
  import { type Ref } from "react";
13
13
  /** The input's state and ids. */
@@ -7,11 +7,12 @@
7
7
  * always mounted, so a message that appears is announced.
8
8
  * @author David @dvhsh (https://dvh.sh)
9
9
  * @created Sat Oct 3, 2026
10
- * @modified Sat Oct 3, 2026
10
+ * @modified Sun Oct 4, 2026
11
11
  */
12
12
  "use client";
13
13
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  import { useId } from "react";
15
+ import { TEXT_TONES } from "../basics/textTones.js";
15
16
  /**
16
17
  * @function PaletteInput
17
18
  * @param props {PaletteInputProps} value, placeholder, name, crumbs, list and active ids, error
@@ -19,5 +20,5 @@ import { useId } from "react";
19
20
  */
20
21
  export function PaletteInput({ ref, value, placeholder, label, crumbs, listId, activeId, error, onChange, }) {
21
22
  const errorId = `${useId()}-error`;
22
- return (_jsxs("div", { className: "border-b4 border-b has-[input:focus-visible]:border-h1", children: [_jsxs("div", { className: "flex items-center gap-2 px-4 py-3", children: [crumbs.map((crumb, index) => (_jsx("span", { className: "shrink-0 rounded bg-b4 px-2 py-0.5 text-c3 text-xs", children: crumb }, index))), _jsx("input", { ref: ref, type: "text", role: "combobox", "aria-expanded": "true", "aria-controls": listId, "aria-activedescendant": activeId, "aria-autocomplete": "list", "aria-label": label, "aria-invalid": error ? true : undefined, "aria-describedby": errorId, autoComplete: "off", spellCheck: false, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value), className: "min-w-0 flex-1 bg-transparent text-base text-c1 outline-hidden placeholder:text-c4 sm:text-lg" })] }), _jsx("p", { id: errorId, role: "status", className: error ? "px-4 pb-2 text-rose-300 text-xs" : "sr-only", children: error })] }));
23
+ return (_jsxs("div", { className: "border-b4 border-b has-[input:focus-visible]:border-h1", children: [_jsxs("div", { className: "flex items-center gap-2 px-4 py-3", children: [crumbs.map((crumb, index) => (_jsx("span", { className: "shrink-0 rounded bg-b4 px-2 py-0.5 text-c3 text-xs", children: crumb }, index))), _jsx("input", { ref: ref, type: "text", role: "combobox", "aria-expanded": "true", "aria-controls": listId, "aria-activedescendant": activeId, "aria-autocomplete": "list", "aria-label": label, "aria-invalid": error ? true : undefined, "aria-describedby": errorId, autoComplete: "off", spellCheck: false, value: value, placeholder: placeholder, onChange: (event) => onChange(event.target.value), className: "min-w-0 flex-1 bg-transparent text-base text-c1 outline-hidden placeholder:text-c4 sm:text-lg" })] }), _jsx("p", { id: errorId, role: "status", className: error ? `px-4 pb-2 text-xs ${TEXT_TONES.error}` : "sr-only", children: error })] }));
23
24
  }
@@ -6,7 +6,7 @@
6
6
  * on the dialog, so this isn't focusable (aria-activedescendant names it).
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Sat Oct 3, 2026
9
- * @modified Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  import type { FuzzyMatch } from "./fuzzy.js";
12
12
  import type { Command } from "./types.js";
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { displayShortcut, parseShortcut } from "./hotkeys.js";
3
3
  // The active row shows its state with an h1 edge, not background alone: bg-b4 on b6 is only
4
- // 1.5:1. min-h-9 keeps every row a 36px target.
5
- const ROW = "flex min-h-9 cursor-default items-center gap-3 rounded-lg border-l-2 border-transparent px-3 py-2";
4
+ // 1.5:1. min-h-9 keeps every row a 36px target, 44px on a coarse pointer.
5
+ const ROW = "flex min-h-9 cursor-default items-center gap-3 rounded-lg border-l-2 border-transparent px-3 py-2 coarse:min-h-11";
6
6
  const ACTIVE = `${ROW} border-h1 bg-b4 forced-colors:border-[Highlight] forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]`;
7
- const KBD = "rounded border border-b3 bg-b5 px-1.5 font-sans text-c3 text-xs";
7
+ const KBD = "rounded border border-b3 bg-b5 px-1.5 font-sans text-c3 text-xs contrast-more:border-c4";
8
8
  const highlighted = (title, ranges) => {
9
9
  const parts = [];
10
10
  let at = 0;
@@ -6,7 +6,7 @@
6
6
  * a click on a link, or focus leaving it closes it too.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Sat Oct 3, 2026
9
+ * @modified Sun Oct 4, 2026
10
10
  */
11
11
  import { type ComponentProps, type ReactNode } from "react";
12
12
  /** One link in the panel. */
@@ -6,14 +6,14 @@
6
6
  * a click on a link, or focus leaving it closes it too.
7
7
  * @author David @dvhsh (https://dvh.sh)
8
8
  * @created Mon Sep 28, 2026
9
- * @modified Sat Oct 3, 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 { useEffect, useId, useRef, useState } from "react";
14
14
  import { cx } from "../../utils/cx.js";
15
15
  import { AutoLink } from "../basics/AutoLink.js";
16
- const ITEM = "block rounded px-3 py-2 font-bold text-c2 text-sm hover:bg-b4 hover:text-c1";
16
+ const ITEM = "block rounded px-3 py-2 font-bold text-c2 text-sm hover:bg-b4 hover:text-c1 coarse:py-3";
17
17
  /**
18
18
  * @function HeaderMenu
19
19
  * @param props {HeaderMenuProps} the button's content, the links, extra controls as children, the
@@ -54,5 +54,5 @@ export function HeaderMenu({ label, items = [], children, align = "end", buttonL
54
54
  box?.removeEventListener("focusout", onFocusOut);
55
55
  };
56
56
  }, [open]);
57
- return (_jsxs("div", { ref: wrapper, className: cx("relative", className), ...props, children: [_jsx("button", { ref: button, type: "button", "aria-expanded": open, "aria-controls": panelId, "aria-label": buttonLabel, onClick: () => setOpen(!open), className: cx("flex min-h-6 items-center gap-2 font-bold text-c1 text-sm transition-colors hover:text-h1", buttonClassName), children: label }), _jsxs("div", { id: panelId, hidden: !open, className: cx("absolute z-10 mt-2 w-44 flex-col gap-1 rounded-lg border border-b3 bg-b6 p-2", open && "flex", align === "end" ? "right-0" : "left-0"), children: [items.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1", children: items.map((item) => (_jsx("li", { children: _jsx(AutoLink, { href: item.href, className: ITEM, onClick: () => setOpen(false), children: item.label }) }, item.href))) })) : null, children] })] }));
57
+ return (_jsxs("div", { ref: wrapper, className: cx("relative", className), ...props, children: [_jsx("button", { ref: button, type: "button", "aria-expanded": open, "aria-controls": panelId, "aria-label": buttonLabel, onClick: () => setOpen(!open), className: cx("flex coarse:min-h-11 min-h-6 items-center gap-2 font-bold text-c1 text-sm transition-colors hover:text-h1", buttonClassName), children: label }), _jsxs("div", { id: panelId, hidden: !open, className: cx("absolute z-10 mt-2 w-44 flex-col gap-1 rounded-lg border border-b3 bg-b6 p-2 contrast-more:border-c4", open && "flex", align === "end" ? "right-0" : "left-0"), children: [items.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1", children: items.map((item) => (_jsx("li", { children: _jsx(AutoLink, { href: item.href, className: ITEM, onClick: () => setOpen(false), children: item.label }) }, item.href))) })) : null, children] })] }));
58
58
  }
@@ -5,7 +5,7 @@
5
5
  * loads its own URL. Server-safe: the caller says which link is current.
6
6
  * @author David @dvhsh (https://dvh.sh)
7
7
  * @created Mon Sep 28, 2026
8
- * @modified Mon Sep 28, 2026
8
+ * @modified Sun Oct 4, 2026
9
9
  */
10
10
  import type { ComponentProps, ReactNode } from "react";
11
11
  /** One tab: where it links, its text, and whether it is the page being shown. */
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from "../../utils/cx.js";
3
3
  import { AutoLink } from "../basics/AutoLink.js";
4
- const TAB = "rounded-full px-3 py-1 font-bold text-sm transition-colors";
4
+ const TAB = "inline-flex items-center rounded-full px-3 py-1 font-bold text-sm transition-colors coarse:min-h-11";
5
5
  // Forced-colors mode drops the pill's background, so the current tab is underlined there.
6
6
  const CURRENT = "bg-b3 text-c1 forced-colors:underline";
7
7
  const OTHER = "text-c3 hover:text-c1";
package/dist/index.d.ts CHANGED
@@ -23,8 +23,11 @@ export { Notice, type NoticeProps, type NoticeTone } from "./components/basics/N
23
23
  export { PageHeader, type PageHeaderProps } from "./components/basics/PageHeader.js";
24
24
  export { Prose, type ProseProps } from "./components/basics/Prose.js";
25
25
  export { type TabItem, Tabs, type TabsProps } from "./components/basics/Tabs.js";
26
+ export { Text, type TextProps } from "./components/basics/Text.js";
26
27
  export { TextLink, type TextLinkProps } from "./components/basics/TextLink.js";
27
28
  export { tabId, tabPanelId } from "./components/basics/tabIds.js";
29
+ export { type TextClassOptions, type TextSize, type TextTone, textClasses, } from "./components/basics/textStyles.js";
30
+ export { useMotionAllowed } from "./components/basics/useMotionAllowed.js";
28
31
  export { BrandPage, type BrandPageAsset, type BrandPageFont, type BrandPageProps, } from "./components/brand/BrandPage.js";
29
32
  export { BrandSwatch, type BrandSwatchProps } from "./components/brand/BrandSwatch.js";
30
33
  export { ContentIndex, type ContentIndexProps } from "./components/content/ContentIndex.js";
@@ -58,7 +61,7 @@ export { HaruhimeWordmark, type HaruhimeWordmarkProps, } from "./components/icon
58
61
  export { HaruhimeWordmarkLink, type HaruhimeWordmarkLinkProps, } from "./components/icons/HaruhimeWordmarkLink.js";
59
62
  export { JsonLd, type JsonLdProps } from "./components/meta/JsonLd.js";
60
63
  export { type BeatmapStatKey, BeatmapStats, type BeatmapStatsProps, } from "./components/osu/BeatmapStats.js";
61
- export { ModBadge, type ModBadgeProps } from "./components/osu/ModBadge.js";
64
+ export { ModBadge, type ModBadgeColor, type ModBadgeProps, } from "./components/osu/ModBadge.js";
62
65
  export { PlayerCard, type PlayerCardProps, type PlayerTeam, } from "./components/osu/PlayerCard.js";
63
66
  export { StarRating, type StarRatingProps } from "./components/osu/StarRating.js";
64
67
  export { CommandPalette } from "./components/palette/CommandPalette.js";
package/dist/index.js CHANGED
@@ -24,8 +24,11 @@ export { Notice } from "./components/basics/Notice.js";
24
24
  export { PageHeader } from "./components/basics/PageHeader.js";
25
25
  export { Prose } from "./components/basics/Prose.js";
26
26
  export { Tabs } from "./components/basics/Tabs.js";
27
+ export { Text } from "./components/basics/Text.js";
27
28
  export { TextLink } from "./components/basics/TextLink.js";
28
29
  export { tabId, tabPanelId } from "./components/basics/tabIds.js";
30
+ export { textClasses, } from "./components/basics/textStyles.js";
31
+ export { useMotionAllowed } from "./components/basics/useMotionAllowed.js";
29
32
  // Brand
30
33
  export { BrandPage, } from "./components/brand/BrandPage.js";
31
34
  export { BrandSwatch } from "./components/brand/BrandSwatch.js";
@@ -64,7 +67,7 @@ export { HaruhimeWordmarkLink, } from "./components/icons/HaruhimeWordmarkLink.j
64
67
  export { JsonLd } from "./components/meta/JsonLd.js";
65
68
  // osu!
66
69
  export { BeatmapStats, } from "./components/osu/BeatmapStats.js";
67
- export { ModBadge } from "./components/osu/ModBadge.js";
70
+ export { ModBadge, } from "./components/osu/ModBadge.js";
68
71
  export { PlayerCard, } from "./components/osu/PlayerCard.js";
69
72
  export { StarRating } from "./components/osu/StarRating.js";
70
73
  // Palette
package/dist/theme.css CHANGED
@@ -13,9 +13,13 @@
13
13
  * (h2 lightness, default 45%) or --h1-l (h1 lightness, default 76%, which clears 4.5:1 on
14
14
  * b5 at every hue) moved to keep text at 4.5:1; the README lists values. Load Nunito with next/font as the --font-nunito variable;
15
15
  * without it the sans stack falls back to the system font.
16
+ *
17
+ * It also follows three browser settings: prefers-reduced-motion (one global rule,
18
+ * data-motion="essential" opts out), prefers-contrast: more (--contrast-lift) and a coarse
19
+ * pointer (the coarse: variant).
16
20
  * @author David @dvhsh (https://dvh.sh)
17
21
  * @created Wed Sep 23, 2026
18
- * @modified Sat Oct 3, 2026
22
+ * @modified Sun Oct 4, 2026
19
23
  */
20
24
 
21
25
  /* This file ships as dist/theme.css, so './' is the package's compiled components. */
@@ -23,9 +27,21 @@
23
27
 
24
28
  :root {
25
29
  --hue: 333;
30
+ --contrast-lift: 0%;
26
31
  color-scheme: dark;
27
32
  }
28
33
 
34
+ /* More contrast: c2, c3, c4 and h1 get lighter and h2 darker by the lift. An app opts out with
35
+ --contrast-lift: 0%; the lift adds to its own --h1-l and --h2-l. */
36
+ @media (prefers-contrast: more) {
37
+ :root {
38
+ --contrast-lift: 8%;
39
+ }
40
+ }
41
+
42
+ /* A touchscreen as the main pointer. The same query as Tailwind's pointer-coarse:, shorter. */
43
+ @custom-variant coarse (@media (pointer: coarse));
44
+
29
45
  @theme inline {
30
46
  --color-b1: hsl(var(--hue) 10% 40%);
31
47
  --color-b2: hsl(var(--hue) 10% 30%);
@@ -35,16 +51,30 @@
35
51
  --color-b6: hsl(var(--hue) 10% 10%);
36
52
 
37
53
  --color-c1: hsl(var(--hue) 40% 100%);
38
- --color-c2: hsl(var(--hue) 40% 90%);
39
- --color-c3: hsl(var(--hue) 40% 80%);
40
- --color-c4: hsl(var(--hue) 40% 70%);
54
+ --color-c2: hsl(var(--hue) 40% calc(var(--c2-l, 90%) + var(--contrast-lift)));
55
+ --color-c3: hsl(var(--hue) 40% calc(var(--c3-l, 80%) + var(--contrast-lift)));
56
+ --color-c4: hsl(var(--hue) 40% calc(var(--c4-l, 70%) + var(--contrast-lift)));
41
57
 
42
- --color-h1: hsl(var(--hue) 100% var(--h1-l, 76%));
43
- --color-h2: hsl(var(--hue) 50% var(--h2-l, 45%));
58
+ --color-h1: hsl(var(--hue) 100% calc(var(--h1-l, 76%) + var(--contrast-lift)));
59
+ --color-h2: hsl(var(--hue) 50% calc(var(--h2-l, 45%) - var(--contrast-lift) / 2));
44
60
 
45
61
  --font-sans: var(--font-nunito), ui-sans-serif, system-ui, sans-serif;
46
62
  }
47
63
 
64
+ /* Motion tokens. A plain @theme, not inline, so utilities read the variables and one change
65
+ reaches every user: duration-short (hovers, presses, color changes), duration-medium (panels,
66
+ dialogs), duration-long (large surfaces); ease-standard, ease-enter, ease-exit. */
67
+ @theme {
68
+ --transition-duration-short: 150ms;
69
+ --transition-duration-medium: 250ms;
70
+ --transition-duration-long: 400ms;
71
+ --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
72
+ --ease-enter: cubic-bezier(0, 0, 0.2, 1);
73
+ --ease-exit: cubic-bezier(0.4, 0, 1, 1);
74
+ --default-transition-duration: var(--transition-duration-short);
75
+ --default-transition-timing-function: var(--ease-standard);
76
+ }
77
+
48
78
  /* CodeBlock reads these as Shiki's CSS-variables theme. Every token keeps 4.5:1 against both b6
49
79
  (the block's background) and b4 (the highlighted-line tint) at every integer --hue. The keyword
50
80
  and link tokens get their own hue-derived value instead of var(--color-h1): that variable's
@@ -69,4 +99,24 @@
69
99
  outline-offset: 2px;
70
100
  border-radius: 4px;
71
101
  }
102
+
103
+ /* The one reduced-motion rule for the family: the browser setting, no in-app toggle. 0.01ms, not
104
+ 0, so transitionend and animationend still fire. !important in the base layer beats every
105
+ normal declaration in later layers, so an app's own duration-* or animate-* can't escape it.
106
+ data-motion="essential" keeps motion that carries meaning. Pseudo-elements can't sit inside
107
+ :where() or :not(), so each gets its own selector. */
108
+ @media (prefers-reduced-motion: reduce) {
109
+ :where(*:not([data-motion="essential"], [data-motion="essential"] *)),
110
+ :where(*:not([data-motion="essential"], [data-motion="essential"] *))::before,
111
+ :where(*:not([data-motion="essential"], [data-motion="essential"] *))::after {
112
+ /* biome-ignore lint/complexity/noImportantStyles: reduced motion must beat utility and inline transitions */
113
+ animation-duration: 0.01ms !important;
114
+ /* biome-ignore lint/complexity/noImportantStyles: reduced motion must beat utility and inline transitions */
115
+ animation-iteration-count: 1 !important;
116
+ /* biome-ignore lint/complexity/noImportantStyles: reduced motion must beat utility and inline transitions */
117
+ transition-duration: 0.01ms !important;
118
+ /* biome-ignore lint/complexity/noImportantStyles: reduced motion must beat utility and inline transitions */
119
+ scroll-behavior: auto !important;
120
+ }
121
+ }
72
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haruhimemoe/ui",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "React components for the haruhime.moe osu! tools on Next.js: the osu!-web-style palette as a Tailwind theme, buttons, cards, form fields and confirms, filter controls, tables, osu! beatmap display pieces and player cards, a command palette, content section tooling, brand pages and the site header and footer.",
5
5
  "keywords": [
6
6
  "osu",