diff --git a/src/components/shared/AccentColorPicker.tsx b/src/components/shared/AccentColorPicker.tsx
new file mode 100644
index 00000000..2fe65e54
--- /dev/null
+++ b/src/components/shared/AccentColorPicker.tsx
@@ -0,0 +1,267 @@
+"use client";
+
+import { useState, type KeyboardEvent } from "react";
+import { rovingStep } from "@/components/shared/PrimaryTabs";
+import { DEFAULT_ACCENT } from "@/config/settings";
+import { getBandSmallIconWhiteUrl, getCharacterFaceIconUrl } from "@/lib/cards/assets";
+import type { ThemeBandColor } from "@/lib/masterdata/build-settings";
+import { MD3_DEFAULT_SEED } from "@/lib/md3/scheme";
+
+export interface AccentChoice {
+ id: string;
+ value: string;
+ /** Full accessible label (e.g. "MyGO!!!!! · Chihaya Anon"). */
+ label: string;
+ /** Concise label shown in section headers (e.g. "Chihaya Anon"). */
+ displayLabel: string;
+ color: string;
+ icon?: string | undefined;
+ iconKind?: "emblem" | "face" | undefined;
+}
+
+export interface AccentBandRow {
+ band: AccentChoice;
+ characters: AccentChoice[];
+}
+
+export interface AccentCatalogue {
+ defaultChoice: AccentChoice;
+ rows: AccentBandRow[];
+ allChoices: AccentChoice[];
+}
+
+function normalizeChoiceValue(value: string | undefined): string {
+ return (value ?? "").trim().toUpperCase();
+}
+
+export function buildAccentCatalogue({
+ defaultLabel,
+ bands,
+ resolveAssetUrl = (url) => url,
+}: {
+ defaultLabel: string;
+ bands: readonly ThemeBandColor[];
+ resolveAssetUrl?: ((url: string) => string) | undefined;
+}): AccentCatalogue {
+ const defaultChoice: AccentChoice = {
+ id: "default",
+ value: DEFAULT_ACCENT,
+ label: defaultLabel,
+ displayLabel: defaultLabel,
+ color: MD3_DEFAULT_SEED,
+ };
+
+ const rows: AccentBandRow[] = bands.map((band) => {
+ const bandColor = band.color.toUpperCase();
+ const bandChoice: AccentChoice = {
+ id: `band:${band.bandId}`,
+ value: bandColor,
+ label: band.name,
+ displayLabel: band.name,
+ color: bandColor,
+ icon: resolveAssetUrl(getBandSmallIconWhiteUrl(band.bandId)),
+ iconKind: "emblem",
+ };
+ const characterChoices: AccentChoice[] = (band.characters ?? []).map((character) => {
+ const charColor = character.color.toUpperCase();
+ return {
+ id: `character:${character.characterId}`,
+ value: charColor,
+ label: `${band.name} · ${character.name}`,
+ displayLabel: character.name,
+ color: charColor,
+ icon: resolveAssetUrl(getCharacterFaceIconUrl(character.characterId)),
+ iconKind: "face",
+ };
+ });
+ return { band: bandChoice, characters: characterChoices };
+ });
+
+ const allChoices: AccentChoice[] = [
+ defaultChoice,
+ ...rows.flatMap((row) => [row.band, ...row.characters]),
+ ];
+
+ return { defaultChoice, rows, allChoices };
+}
+
+export function findSelectedAccentChoice(
+ catalogue: AccentCatalogue,
+ value: string | undefined,
+): AccentChoice | undefined {
+ const target = normalizeChoiceValue(value);
+ if (!target) return undefined;
+ return catalogue.allChoices.find((choice) => normalizeChoiceValue(choice.value) === target);
+}
+
+export interface AccentColorPickerProps {
+ label: string;
+ catalogue: AccentCatalogue;
+ value: string | undefined;
+ onChange: (value: string) => void;
+ ringOffsetClass?: string | undefined;
+}
+
+/**
+ * Material 3 theme seed picker offering the default MoeNotes blue, every band's main color (with its white emblem),
+ * and every playable character's main color (with their face portrait on their color), arranged as one compact row
+ * per band (`[Band | 5 Members]`) with full roving-tabindex radio navigation.
+ */
+export default function AccentColorPicker({
+ label,
+ catalogue,
+ value,
+ onChange,
+ ringOffsetClass = "ring-offset-surface-container-low",
+}: AccentColorPickerProps) {
+ const { defaultChoice, rows, allChoices } = catalogue;
+ const normalizedValue = normalizeChoiceValue(value);
+ const selectedIndex = allChoices.findIndex((choice) => normalizeChoiceValue(choice.value) === normalizedValue);
+ const hasCharacterRows = rows.some((row) => row.characters.length > 0);
+
+ const onKeyDown = (event: KeyboardEvent
) => {
+ const next = rovingStep(event.key, selectedIndex, allChoices.length);
+ if (next === null) return;
+ event.preventDefault();
+ onChange(allChoices[next]!.value);
+ event.currentTarget.querySelectorAll('[role="radio"]')[next]?.focus();
+ };
+
+ if (!hasCharacterRows) {
+ return (
+
+ {allChoices.map((choice, index) => (
+
+ ))}
+
+ );
+ }
+
+ let optionCursor = 0;
+ const defaultIndex = optionCursor++;
+ const defaultSelected = defaultIndex === selectedIndex;
+
+ return (
+
+
+
+
+
+
+ {rows.map((row) => {
+ const bandIndex = optionCursor++;
+ const charIndices = row.characters.map(() => optionCursor++);
+ return (
+
+
+ {row.characters.length > 0 && (
+ <>
+
+
+ {row.characters.map((character, idx) => {
+ const choiceIndex = charIndices[idx]!;
+ return (
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+function SwatchRadio({
+ choice,
+ selected,
+ tabIndex,
+ ringOffsetClass,
+ onSelect,
+}: {
+ choice: AccentChoice;
+ selected: boolean;
+ tabIndex: number;
+ ringOffsetClass: string;
+ onSelect: (value: string) => void;
+}) {
+ return (
+
+ );
+}
+
+function SwatchIcon({ src, kind }: { src: string; kind: "emblem" | "face" }) {
+ const [failed, setFailed] = useState(false);
+ if (failed) return null;
+ return (
+
setFailed(true)}
+ className={kind === "face" ? "h-full w-full object-cover" : "h-[72%] w-[72%] object-contain"}
+ />
+ );
+}
diff --git a/src/components/shell/SettingsDrawer.tsx b/src/components/shell/SettingsDrawer.tsx
index 5f15ca9a..2d55e28a 100644
--- a/src/components/shell/SettingsDrawer.tsx
+++ b/src/components/shell/SettingsDrawer.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useId, useState, type KeyboardEvent, type ReactNode, type SyntheticEvent } from "react";
+import { useCallback, useEffect, useId, useMemo, useState, type ReactNode, type SyntheticEvent } from "react";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
@@ -11,13 +11,13 @@ import TextField from "@mui/material/TextField";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import useMediaQuery from "@mui/material/useMediaQuery";
+import AccentColorPicker, { buildAccentCatalogue, findSelectedAccentChoice } from "@/components/shared/AccentColorPicker";
import { Icon, type IconName } from "@/components/shared/icons";
-import PrimaryTabs, { rovingStep } from "@/components/shared/PrimaryTabs";
+import PrimaryTabs from "@/components/shared/PrimaryTabs";
import { withMd3 } from "@/components/md3/Md3Provider";
import type { AppLocale } from "@/config/locales";
import { LOCALE_LABELS, SUPPORTED_LOCALES, flagIconSrc } from "@/config/locales";
import { GAME_SERVER_PROFILES, GAME_SERVERS, flagIconSrc as serverFlagIconSrc } from "@/config/servers";
-import { DEFAULT_ACCENT } from "@/config/settings";
import { switchLocalePath } from "@/i18n/routing";
import { t } from "@/i18n";
import {
@@ -29,7 +29,6 @@ import {
type CacheCategory,
type CacheOverview,
} from "@/lib/cache/usage";
-import { getBandSmallIconWhiteUrl } from "@/lib/cards/assets";
import { formatBytes } from "@/lib/format/bytes";
import { defaultGameServer } from "@/lib/game-api/server";
import type { ThemeBandColor } from "@/lib/masterdata/build-settings";
@@ -41,7 +40,6 @@ import {
getLocalServerMasterDataVersion,
type LiveMasterDataVersionPayload,
} from "@/lib/masterdata/client-version";
-import { MD3_DEFAULT_SEED } from "@/lib/md3/scheme";
import { getForceJapaneseTitles, setForceJapaneseTitles, SONG_TITLE_PREFERENCE_EVENT } from "@/lib/music/title-preference";
import { useOverlay } from "@/lib/overlay/use-overlay";
import { useOverlayLayer } from "@/lib/overlay/use-overlay-layer";
@@ -54,7 +52,7 @@ import type { AppSettings } from "@/types/settings";
interface SettingsDrawerProps {
locale: AppLocale;
pathname: string;
- /** Band colors the theme color choice offers (build time, src/lib/masterdata/build-settings.ts). */
+ /** Band and character colors the theme color choice offers (build time, src/lib/masterdata/build-settings.ts). */
bands?: ThemeBandColor[];
}
@@ -157,15 +155,16 @@ function SettingsDrawer({ locale, pathname, bands = [] }: SettingsDrawerProps) {
const flag = (src: string) =>
;
/** How the site looks: color scheme, theme color, density, and the interface language. */
-function AppearanceSettings({ locale, pathname, bands = [] }: SettingsDrawerProps) {
+export function AppearanceSettings({ locale, pathname, bands = [] }: SettingsDrawerProps) {
const { settings, updateSettings } = useSettings();
const update = (patch: Partial) => updateSettings(patch);
const assetUrl = useAssetUrl();
- const accents: AccentOption[] = [
- { key: DEFAULT_ACCENT, label: t(locale, "settings.accent.default"), color: MD3_DEFAULT_SEED },
- ...bands.map((band) => ({ key: band.color, label: band.name, color: band.color, icon: assetUrl(getBandSmallIconWhiteUrl(band.bandId)) })),
- ];
- const accent = accents.find((option) => option.key.toUpperCase() === settings.accentColor.toUpperCase());
+ const defaultLabel = t(locale, "settings.accent.default");
+ const catalogue = useMemo(
+ () => buildAccentCatalogue({ defaultLabel, bands, resolveAssetUrl: assetUrl }),
+ [defaultLabel, bands, assetUrl],
+ );
+ const accent = findSelectedAccentChoice(catalogue, settings.accentColor);
return (
@@ -183,13 +182,18 @@ function AppearanceSettings({ locale, pathname, bands = [] }: SettingsDrawerProp
-
- {accent.label}
+
+
+ {accent.displayLabel}
)}
>
- update({ accentColor })} />
+ update({ accentColor })}
+ />
@@ -540,68 +544,6 @@ function CacheBreakdown({ locale, overview, active, onActive }: {
);
}
-interface AccentOption {
- key: string;
- label: string;
- color: string;
- /** The band's emblem in white, drawn on its color as MoeSekai draws a character's face on theirs. */
- icon?: string;
-}
-
-/**
- * Theme color: the site's default blue or a band's color, as MoeSekai's swatches: round, the chosen one a rounded
- * square ringed in the primary color. The section heading names the chosen one.
- */
-function AccentPicker({ label, options, value, onChange }: {
- label: string;
- options: AccentOption[];
- value: string | undefined;
- onChange: (value: string) => void;
-}) {
- const selectedIndex = options.findIndex((option) => option.key === value);
- const onKeyDown = (event: KeyboardEvent) => {
- const next = rovingStep(event.key, selectedIndex, options.length);
- if (next === null) return;
- event.preventDefault();
- onChange(options[next]!.key);
- event.currentTarget.querySelectorAll('[role="radio"]')[next]?.focus();
- };
- return (
- // 10px apart: seven swatches still fit one row of a phone's sheet, the ring and its offset clear of the neighbors.
-
- {options.map((option, index) => {
- const selected = index === selectedIndex;
- return (
-
- );
- })}
-
- );
-}
-
-/** A band emblem on its swatch; a band without one keeps the plain color. */
-function SwatchIcon({ src }: { src: string }) {
- const [failed, setFailed] = useState(false);
- if (failed) return null;
- return
setFailed(true)} className="h-[70%] w-[70%] object-contain" />;
-}
-
/** Always show songs under their Japanese titles (shared with the music pages' preference). */
function JapaneseTitlesToggle({ locale }: { locale: AppLocale }) {
const [on, setOn] = useState(false);
diff --git a/src/lib/characters/data.ts b/src/lib/characters/data.ts
index bb5de8d7..ca825094 100644
--- a/src/lib/characters/data.ts
+++ b/src/lib/characters/data.ts
@@ -7,6 +7,7 @@ export interface RawCharacter {
id: number;
bandID: number;
displayOrder: number;
+ isNonPlayable?: boolean | undefined;
nameTextID: string;
enDisplayNameTextId: string;
shortNameTextID: string;
@@ -32,6 +33,7 @@ export interface CharacterViewModel {
id: number;
bandId: number;
displayOrder: number;
+ isNonPlayable?: boolean | undefined;
name: string;
enName: string;
shortName: string;
@@ -90,6 +92,7 @@ export function normalizeCharacters(
id: char.id,
bandId: char.bandID,
displayOrder: char.displayOrder,
+ isNonPlayable: Boolean(char.isNonPlayable),
name,
enName,
shortName,
diff --git a/src/lib/masterdata/build-settings.ts b/src/lib/masterdata/build-settings.ts
index 5c2c2ee0..f50e453b 100644
--- a/src/lib/masterdata/build-settings.ts
+++ b/src/lib/masterdata/build-settings.ts
@@ -2,24 +2,101 @@ import type { AppLocale } from "@/config/locales";
import { getBuildCharacters } from "@/lib/masterdata/build-data";
import { memo } from "@/lib/masterdata/build-core";
-/** A theme color the settings offer: a band's main color (MasterBand.mainColorCode). */
+/** A playable character's theme color (MasterCharacter.mainColorCode) grouped under their band. */
+export interface ThemeCharacterColor {
+ characterId: number;
+ bandId: number;
+ name: string;
+ color: string;
+}
+
+/** A band's theme color (MasterBand.mainColorCode) and its playable members' theme colors. */
export interface ThemeBandColor {
bandId: number;
name: string;
color: string;
+ characters?: ThemeCharacterColor[] | undefined;
+}
+
+interface RawBandColorInput {
+ id: number;
+ name: string;
+ color: string;
+}
+
+interface RawCharacterColorInput {
+ id: number;
+ bandId: number;
+ displayOrder: number;
+ isNonPlayable?: boolean | undefined;
+ name: string;
+ mainColor: string;
+}
+
+/** Normalizes `#RRGGBB` or `RRGGBB` into upper-case `#RRGGBB`, or returns `null` when invalid. */
+export function normalizeThemeHexColor(value: string | undefined): string | null {
+ if (!value) return null;
+ const trimmed = value.trim();
+ const hex = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
+ return /^#[0-9a-f]{6}$/i.test(hex) ? hex.toUpperCase() : null;
+}
+
+/**
+ * Builds the ordered band and playable-character theme color catalogue from normalized MasterBand / MasterCharacter rows.
+ * Non-playable characters (e.g. story-only NPCs with placeholder `#FFFFFF` colors) are excluded.
+ */
+export function buildThemeBandColors(
+ bands: readonly RawBandColorInput[],
+ characters: readonly RawCharacterColorInput[],
+): ThemeBandColor[] {
+ const charactersByBand = new Map>();
+
+ for (const character of characters) {
+ if (character.isNonPlayable === true) continue;
+ const color = normalizeThemeHexColor(character.mainColor);
+ if (!color) continue;
+ const list = charactersByBand.get(character.bandId) ?? [];
+ list.push({
+ characterId: character.id,
+ bandId: character.bandId,
+ name: character.name,
+ color,
+ displayOrder: character.displayOrder,
+ });
+ charactersByBand.set(character.bandId, list);
+ }
+
+ return [...bands]
+ .sort((a, b) => a.id - b.id)
+ .flatMap((band) => {
+ const color = normalizeThemeHexColor(band.color);
+ if (!color) return [];
+ const bandCharacters = (charactersByBand.get(band.id) ?? [])
+ .sort((a, b) => a.displayOrder - b.displayOrder || a.characterId - b.characterId)
+ .map(({ characterId, bandId, name, color: charColor }) => ({
+ characterId,
+ bandId,
+ name,
+ color: charColor,
+ }));
+ return [{
+ bandId: band.id,
+ name: band.name,
+ color,
+ characters: bandCharacters,
+ }];
+ });
}
/**
- * Every band of any server with a usable main color, in MasterBand order, for the settings' theme color choice.
- * Empty when the tables cannot be read (the choice then offers the default only).
+ * Every band and playable character of any server with a usable main color, in MasterBand / stage display order,
+ * for the settings' theme color choice. Empty when the tables cannot be read (the choice then offers the default only).
*/
export function getBuildThemeBands(locale: AppLocale): Promise {
return memo(`theme-bands:${locale}`, async () => {
try {
- const { bands } = await getBuildCharacters(locale);
- return bands
- .filter((band) => /^#[0-9a-f]{6}$/i.test(band.color))
- .map((band) => ({ bandId: band.id, name: band.name, color: band.color.toUpperCase() }));
+ const { bands, characters } = await getBuildCharacters(locale);
+ return buildThemeBandColors(bands, characters);
} catch {
return [];
}
diff --git a/src/lib/md3/scheme.ts b/src/lib/md3/scheme.ts
index 172fa430..b1edf146 100644
--- a/src/lib/md3/scheme.ts
+++ b/src/lib/md3/scheme.ts
@@ -1,6 +1,7 @@
/**
- * Material 3 schemes from one seed color (the default brand blue, or a band's MasterBand.mainColorCode chosen in the
- * settings), on the recipe MoeSekai uses: a neutral stage and the seed's own color for accents.
+ * Material 3 schemes from one seed color (the default brand blue, a band's MasterBand.mainColorCode, or a character's
+ * MasterCharacter.mainColorCode chosen in the settings), on the recipe MoeSekai uses: a neutral stage and the seed's
+ * own color for accents.
*
* - Page, cards and outlines are a set of cool neutral greys that do not depend on the seed (NEUTRAL_SURFACES):
* surfaces derived from the seed lay a cast over the whole page, and pale seeds turn it grey and dull.
diff --git a/src/lib/settings/accent.ts b/src/lib/settings/accent.ts
index f0c2bfe4..55c393c3 100644
--- a/src/lib/settings/accent.ts
+++ b/src/lib/settings/accent.ts
@@ -1,8 +1,8 @@
/**
- * Theme colors from one seed (a band's MasterBand.mainColorCode chosen in the settings): the full Material 3 light
- * and dark schemes (src/lib/md3/scheme.ts). The chosen seed's schemes are cached in browser storage when the
- * settings are applied, so the head's bootstrap script (src/lib/settings/apply-theme.ts) sets them before the first
- * paint without computing anything.
+ * Theme colors from one seed (a band's MasterBand.mainColorCode or a character's MasterCharacter.mainColorCode chosen
+ * in the settings): the full Material 3 light and dark schemes (src/lib/md3/scheme.ts). The chosen seed's schemes are
+ * cached in browser storage when the settings are applied, so the head's bootstrap script
+ * (src/lib/settings/apply-theme.ts) sets them before the first paint without computing anything.
*/
import { md3Schemes } from "@/lib/md3/scheme";
import type { Md3Schemes } from "@/lib/md3/roles";
diff --git a/tests/accent-color-picker.test.tsx b/tests/accent-color-picker.test.tsx
new file mode 100644
index 00000000..f4bbcae7
--- /dev/null
+++ b/tests/accent-color-picker.test.tsx
@@ -0,0 +1,127 @@
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { Md3Provider } from "../src/components/md3/Md3Provider";
+import AccentColorPicker, {
+ buildAccentCatalogue,
+ findSelectedAccentChoice,
+} from "../src/components/shared/AccentColorPicker";
+import { AppearanceSettings } from "../src/components/shell/SettingsDrawer";
+import { t } from "../src/i18n";
+import {
+ buildThemeBandColors,
+ normalizeThemeHexColor,
+} from "../src/lib/masterdata/build-settings";
+
+const CORE_LOCALES = ["zh-CN", "zh-TW", "ja-JP", "en-US", "ko-KR"] as const;
+
+describe("theme band and character color builder", () => {
+ test("normalizes hex codes, sorts bands and characters in display order, and excludes non-playable NPCs", () => {
+ const bands = [
+ { id: 4, name: "millsage", color: "#aa22ee" },
+ { id: 1, name: "MyGO!!!!!", color: "3388bb" },
+ { id: 99, name: "Invalid", color: "var(--md-sys-color-primary)" },
+ ];
+ const characters = [
+ { id: 1, bandId: 1, displayOrder: 3, isNonPlayable: false, name: "Tomori", mainColor: "#77BBDD" },
+ { id: 2, bandId: 1, displayOrder: 1, isNonPlayable: false, name: "Anon", mainColor: "#ff8899" },
+ { id: 18, bandId: 4, displayOrder: 1, isNonPlayable: false, name: "Nagi", mainColor: "#5555FF" },
+ { id: 1001, bandId: 4, displayOrder: 6, isNonPlayable: true, name: "Kanata", mainColor: "#FFFFFF" },
+ { id: 999, bandId: 1, displayOrder: 9, isNonPlayable: false, name: "Broken", mainColor: "not-a-color" },
+ ];
+
+ expect(normalizeThemeHexColor("#bb9955")).toBe("#BB9955");
+ expect(normalizeThemeHexColor(" 3388bb ")).toBe("#3388BB");
+ expect(normalizeThemeHexColor("default")).toBeNull();
+
+ const result = buildThemeBandColors(bands, characters);
+ expect(result).toEqual([
+ {
+ bandId: 1,
+ name: "MyGO!!!!!",
+ color: "#3388BB",
+ characters: [
+ { characterId: 2, bandId: 1, name: "Anon", color: "#FF8899" },
+ { characterId: 1, bandId: 1, name: "Tomori", color: "#77BBDD" },
+ ],
+ },
+ {
+ bandId: 4,
+ name: "millsage",
+ color: "#AA22EE",
+ characters: [
+ { characterId: 18, bandId: 4, name: "Nagi", color: "#5555FF" },
+ ],
+ },
+ ]);
+ });
+});
+
+describe("AccentColorPicker and AppearanceSettings", () => {
+ const sampleBands = buildThemeBandColors(
+ [
+ { id: 1, name: "MyGO!!!!!", color: "#3388BB" },
+ { id: 2, name: "Ave Mujica", color: "#881144" },
+ ],
+ [
+ { id: 2, bandId: 1, displayOrder: 1, isNonPlayable: false, name: "Anon", mainColor: "#FF8899" },
+ { id: 1, bandId: 1, displayOrder: 3, isNonPlayable: false, name: "Tomori", mainColor: "#77BBDD" },
+ { id: 10, bandId: 2, displayOrder: 1, isNonPlayable: false, name: "Sakiko", mainColor: "#7799cc" },
+ ],
+ );
+
+ test("builds default, band emblem, and character face choices with case-insensitive lookup", () => {
+ const catalogue = buildAccentCatalogue({
+ defaultLabel: "Default",
+ bands: sampleBands,
+ resolveAssetUrl: (url) => url,
+ });
+
+ expect(catalogue.allChoices.map((choice) => [choice.id, choice.value, choice.displayLabel])).toEqual([
+ ["default", "default", "Default"],
+ ["band:1", "#3388BB", "MyGO!!!!!"],
+ ["character:2", "#FF8899", "Anon"],
+ ["character:1", "#77BBDD", "Tomori"],
+ ["band:2", "#881144", "Ave Mujica"],
+ ["character:10", "#7799CC", "Sakiko"],
+ ]);
+
+ expect(findSelectedAccentChoice(catalogue, "#7799cc")?.id).toBe("character:10");
+ expect(findSelectedAccentChoice(catalogue, "#3388BB")?.id).toBe("band:1");
+ expect(findSelectedAccentChoice(catalogue, "default")?.id).toBe("default");
+ expect(catalogue.rows[0]!.band.icon).toContain("band_small_icon_white");
+ expect(catalogue.rows[0]!.characters[0]!.icon).toContain("Character/Image/2/character_face_icon");
+ });
+
+ test("marks the chosen character swatch as checked and renders band + character rows", () => {
+ const catalogue = buildAccentCatalogue({ defaultLabel: "Default", bands: sampleBands });
+ const html = renderToStaticMarkup(
+ undefined}
+ />,
+ );
+
+ expect(html).toContain('role="radiogroup"');
+ expect(html).toContain('aria-label="MyGO!!!!! · Tomori"');
+ expect(html).toContain('aria-label="Ave Mujica · Sakiko"');
+ const checkedMatches = html.match(/aria-checked="true"/g) ?? [];
+ expect(checkedMatches).toHaveLength(1);
+ expect(html).toContain('aria-checked="true" aria-label="MyGO!!!!! · Tomori"');
+ });
+
+ test("renders AppearanceSettings with band and character swatches across all 5 core locales", () => {
+ for (const locale of CORE_LOCALES) {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ expect(html).toContain(t(locale, "settings.accent.title"));
+ expect(html).toContain(t(locale, "settings.accent.default"));
+ expect(html).toContain("MyGO!!!!! · Anon");
+ expect(html).toContain("Ave Mujica · Sakiko");
+ }
+ });
+});
diff --git a/tests/settings-theme.test.ts b/tests/settings-theme.test.ts
index 468eed6f..1400d432 100644
--- a/tests/settings-theme.test.ts
+++ b/tests/settings-theme.test.ts
@@ -23,9 +23,23 @@ describe("settings", () => {
});
});
-describe("material 3 schemes from band colors", () => {
- // MasterBand.mainColorCode of the five bands (TW, 2026-10), the default seed, plus a very light and a very dark color.
- const seeds = [MD3_DEFAULT_SEED, "#3388BB", "#881144", "#FF7788", "#AA22EE", "#FFAA33", "#FFFF00", "#101010"];
+describe("material 3 schemes from band and character colors", () => {
+ // MasterBand.mainColorCode of the five bands and MasterCharacter.mainColorCode of all 25 playable characters
+ // (TW/JP/EN/KR 2026-11), the default seed, plus a very light and a very dark color.
+ const bandSeeds = ["#3388BB", "#881144", "#FF7788", "#AA22EE", "#FFAA33"];
+ const characterSeeds = [
+ // MyGO!!!!!
+ "#77BBDD", "#FF8899", "#77DD77", "#FFDD88", "#7777AA",
+ // Ave Mujica
+ "#BB9955", "#779977", "#335566", "#AA4477", "#7799CC",
+ // Mugendai Mewtype
+ "#FFEE55", "#FFBBCC", "#4477CC", "#9977CC", "#EE5577",
+ // millsage
+ "#99FF99", "#FF4444", "#5555FF", "#77FFFF", "#EE99EE",
+ // Ikka Dumb Rock!
+ "#FF7700", "#22CCFF", "#448888", "#FF55AA", "#9999FF",
+ ];
+ const seeds = [MD3_DEFAULT_SEED, ...bandSeeds, ...characterSeeds, "#FFFF00", "#101010"];
// Content / container pairs that carry text: M3 guarantees 4.5:1 for each at standard contrast.
const textPairs = [
["onPrimary", "primary"],