diff --git a/src/components/design-system/DesignSystemPage.tsx b/src/components/design-system/DesignSystemPage.tsx index e6efda17..594c52ee 100644 --- a/src/components/design-system/DesignSystemPage.tsx +++ b/src/components/design-system/DesignSystemPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import Alert from "@mui/material/Alert"; import Autocomplete from "@mui/material/Autocomplete"; import Badge from "@mui/material/Badge"; @@ -35,13 +35,13 @@ import ToggleButton from "@mui/material/ToggleButton"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; +import AccentColorPicker, { buildAccentCatalogue, findSelectedAccentChoice } from "@/components/shared/AccentColorPicker"; import BrandLogo from "@/components/shared/BrandLogo"; import { withMd3 } from "@/components/md3/Md3Provider"; import type { AppLocale } from "@/config/locales"; -import { DEFAULT_ACCENT } from "@/config/settings"; import { t } from "@/i18n"; -import { MD3_DEFAULT_SEED } from "@/lib/md3/scheme"; import type { ThemeBandColor } from "@/lib/masterdata/build-settings"; +import { useAssetUrl } from "@/lib/servers/use-content-server"; import { useSettings } from "@/lib/settings/use-settings"; import FoundationSection from "./FoundationSection"; @@ -114,28 +114,33 @@ function ColorGroup({ locale, group }: { locale: AppLocale; group: string }) { function ThemeControls({ locale, bands }: Props) { const { settings, updateSettings } = useSettings(); - const seeds = [{ key: DEFAULT_ACCENT, label: t(locale, k("seedDefault")), color: MD3_DEFAULT_SEED }, ...bands.map((band) => ({ key: band.color, label: band.name, color: band.color }))]; + const assetUrl = useAssetUrl(); + const defaultLabel = t(locale, k("seedDefault")); + const catalogue = useMemo( + () => buildAccentCatalogue({ defaultLabel, bands, resolveAssetUrl: assetUrl }), + [defaultLabel, bands, assetUrl], + ); + const activeChoice = findSelectedAccentChoice(catalogue, settings.accentColor); const resolvedTheme = settings.colorScheme === "dark" ? "dark" : settings.colorScheme === "light" ? "light" : null; return (
-
{t(locale, k("seed"))}
-
- {seeds.map((seed) => { - const selected = settings.accentColor === seed.key; - return ( - updateSettings({ accentColor: seed.key })} - icon={} - label={seed.label} - /> - ); - })} +
+
{t(locale, k("seed"))}
+ {activeChoice && ( +
+ updateSettings({ accentColor })} + />

{t(locale, k("seedHint"))}

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 && ( + <> +
+ ); + })} +
+
+ ); +} + +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
@@ -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"],