diff --git a/packages/i18n/src/core/am.json b/packages/i18n/src/core/am.json
index 364165fa..fc14f2a9 100644
--- a/packages/i18n/src/core/am.json
+++ b/packages/i18n/src/core/am.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "Ակրիլ",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Ապակի",
- "setting_variant_windowmaterial_solid": "Միագույն"
+ "setting_variant_windowmaterial_solid": "Միագույն",
+ "apply": "Կիրառել",
+ "cosmetic_wardrobe": "Զգեստապահարան",
+ "cosmetic_wardrobe_hint": "Այն, ինչ կրում եք ձեր ավատարի, պրոֆիլի քարտի և անվան վրա։",
+ "cosmetic_kind_avatar_decoration": "Ավատարի զարդ",
+ "cosmetic_kind_profile_frame": "Պրոֆիլի շրջանակ",
+ "cosmetic_kind_nickname_style": "Անվան ոճ",
+ "cosmetic_kind_option_font": "Տառատեսակներ",
+ "cosmetic_kind_option_text_effect": "Անվան էֆեկտներ",
+ "cosmetic_clear": "Հանել",
+ "cosmetic_locked": "Արգելափակված է",
+ "cosmetic_locked_ultima": "Ultima-ով",
+ "cosmetic_locked_grant": "Դեռ ձերը չէ",
+ "cosmetic_available_until": "Հասանելի է մինչև {date}",
+ "cosmetic_surface_card": "Պրոֆիլի քարտ",
+ "cosmetic_surface_avatar": "Ավատար",
+ "cosmetic_surface_message": "Զրուցարանում",
+ "cosmetic_surface_member_row": "Մասնակիցների ցանկ",
+ "cosmetic_equip_failed": "Չհաջողվեց հագնել",
+ "cosmetic_error_network": "Չհաջողվեց կապ հաստատել սերվերի հետ։ Փորձեք կրկին։",
+ "cosmetic_error_not_found": "Այս իրն այլևս գոյություն չունի։",
+ "cosmetic_error_unknown_kind": "Այս տեսակի կոսմետիկան չի աջակցվում։",
+ "cosmetic_error_kind_disabled": "Այս տեսակի կոսմետիկան այժմ անջատված է։",
+ "cosmetic_error_item_unavailable": "Այս իրն այլևս հասանելի չէ։",
+ "cosmetic_error_not_owned": "Դուք դեռ չունեք այս իրը։",
+ "cosmetic_error_choice_invalid": "Ընտրված տարբերակներից մեկն այստեղ չի համապատասխանում։",
+ "cosmetic_error_value_out_of_range": "Կարգավորումներից մեկը թույլատրելի սահմաններից դուրս է։"
}
diff --git a/packages/i18n/src/core/de.json b/packages/i18n/src/core/de.json
index a1f3447b..3ec54810 100644
--- a/packages/i18n/src/core/de.json
+++ b/packages/i18n/src/core/de.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "Acryl",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Glas",
- "setting_variant_windowmaterial_solid": "Einfarbig"
+ "setting_variant_windowmaterial_solid": "Einfarbig",
+ "apply": "Anwenden",
+ "cosmetic_wardrobe": "Garderobe",
+ "cosmetic_wardrobe_hint": "Was du an deinem Avatar, auf deiner Profilkarte und an deinem Namen trägst.",
+ "cosmetic_kind_avatar_decoration": "Avatar-Dekoration",
+ "cosmetic_kind_profile_frame": "Profilrahmen",
+ "cosmetic_kind_nickname_style": "Namensstil",
+ "cosmetic_kind_option_font": "Schriftarten",
+ "cosmetic_kind_option_text_effect": "Namenseffekte",
+ "cosmetic_clear": "Ablegen",
+ "cosmetic_locked": "Gesperrt",
+ "cosmetic_locked_ultima": "Mit Ultima",
+ "cosmetic_locked_grant": "Noch nicht deins",
+ "cosmetic_available_until": "Verfügbar bis {date}",
+ "cosmetic_surface_card": "Profilkarte",
+ "cosmetic_surface_avatar": "Avatar",
+ "cosmetic_surface_message": "Im Chat",
+ "cosmetic_surface_member_row": "Mitgliederliste",
+ "cosmetic_equip_failed": "Konnte nicht angelegt werden",
+ "cosmetic_error_network": "Server nicht erreichbar. Versuch es noch einmal.",
+ "cosmetic_error_not_found": "Dieses Item gibt es nicht mehr.",
+ "cosmetic_error_unknown_kind": "Diese Art von Kosmetik wird nicht unterstützt.",
+ "cosmetic_error_kind_disabled": "Diese Art von Kosmetik ist gerade ausgeschaltet.",
+ "cosmetic_error_item_unavailable": "Dieses Item ist nicht mehr verfügbar.",
+ "cosmetic_error_not_owned": "Du hast dieses Item noch nicht.",
+ "cosmetic_error_choice_invalid": "Eine der gewählten Optionen passt hier nicht.",
+ "cosmetic_error_value_out_of_range": "Eine der Einstellungen liegt außerhalb des erlaubten Bereichs."
}
diff --git a/packages/i18n/src/core/en.json b/packages/i18n/src/core/en.json
index 8724b295..f8f83e27 100644
--- a/packages/i18n/src/core/en.json
+++ b/packages/i18n/src/core/en.json
@@ -63,6 +63,7 @@
"create_new_server": "Create New Server",
"announcement": "Announcement",
"privacy_settings": "Privacy Settings",
+ "apply": "Apply",
"cancel": "Cancel",
"voice": "Voice",
"start_video": "Start Video",
@@ -2546,5 +2547,30 @@
"setting_variant_windowmaterial_acrylic": "Acrylic",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Glass",
- "setting_variant_windowmaterial_solid": "Solid"
+ "setting_variant_windowmaterial_solid": "Solid",
+ "cosmetic_wardrobe": "Wardrobe",
+ "cosmetic_wardrobe_hint": "What you wear on your avatar, your profile card and your name.",
+ "cosmetic_kind_avatar_decoration": "Avatar decoration",
+ "cosmetic_kind_profile_frame": "Profile frame",
+ "cosmetic_kind_nickname_style": "Name style",
+ "cosmetic_kind_option_font": "Fonts",
+ "cosmetic_kind_option_text_effect": "Name effects",
+ "cosmetic_clear": "Take off",
+ "cosmetic_locked": "Locked",
+ "cosmetic_locked_ultima": "With Ultima",
+ "cosmetic_locked_grant": "Not yours yet",
+ "cosmetic_available_until": "Available until {date}",
+ "cosmetic_surface_card": "Profile card",
+ "cosmetic_surface_avatar": "Avatar",
+ "cosmetic_surface_message": "In chat",
+ "cosmetic_surface_member_row": "Member list",
+ "cosmetic_equip_failed": "That could not be put on",
+ "cosmetic_error_network": "Could not reach the server. Try again.",
+ "cosmetic_error_not_found": "That item no longer exists.",
+ "cosmetic_error_unknown_kind": "This kind of cosmetic is not supported.",
+ "cosmetic_error_kind_disabled": "This kind of cosmetic is switched off right now.",
+ "cosmetic_error_item_unavailable": "That item is no longer available.",
+ "cosmetic_error_not_owned": "You don't have that item yet.",
+ "cosmetic_error_choice_invalid": "One of the chosen options can't be used here.",
+ "cosmetic_error_value_out_of_range": "One of the settings is out of range."
}
diff --git a/packages/i18n/src/core/en_tengwar.json b/packages/i18n/src/core/en_tengwar.json
index 44dfc6ae..da171d5b 100644
--- a/packages/i18n/src/core/en_tengwar.json
+++ b/packages/i18n/src/core/en_tengwar.json
@@ -63,6 +63,7 @@
"create_new_server": " ",
"announcement": "",
"privacy_settings": " ",
+ "apply": "",
"cancel": "",
"voice": "",
"start_video": " ",
@@ -2546,5 +2547,30 @@
"setting_variant_windowmaterial_acrylic": "",
"setting_variant_windowmaterial_mica": "",
"setting_variant_windowmaterial_glass": "",
- "setting_variant_windowmaterial_solid": ""
+ "setting_variant_windowmaterial_solid": "",
+ "cosmetic_wardrobe": "",
+ "cosmetic_wardrobe_hint": " ⸱ :",
+ "cosmetic_kind_avatar_decoration": " ",
+ "cosmetic_kind_profile_frame": " ",
+ "cosmetic_kind_nickname_style": " ",
+ "cosmetic_kind_option_font": "",
+ "cosmetic_kind_option_text_effect": " ",
+ "cosmetic_clear": " ",
+ "cosmetic_locked": "",
+ "cosmetic_locked_ultima": " ",
+ "cosmetic_locked_grant": " ",
+ "cosmetic_available_until": " {date}",
+ "cosmetic_surface_card": " ",
+ "cosmetic_surface_avatar": "",
+ "cosmetic_surface_message": " ",
+ "cosmetic_surface_member_row": " ",
+ "cosmetic_equip_failed": " ",
+ "cosmetic_error_network": " : :",
+ "cosmetic_error_not_found": " :",
+ "cosmetic_error_unknown_kind": " :",
+ "cosmetic_error_kind_disabled": " :",
+ "cosmetic_error_item_unavailable": " :",
+ "cosmetic_error_not_owned": " :",
+ "cosmetic_error_choice_invalid": " :",
+ "cosmetic_error_value_out_of_range": " :"
}
diff --git a/packages/i18n/src/core/es.json b/packages/i18n/src/core/es.json
index f8bc396e..24443758 100644
--- a/packages/i18n/src/core/es.json
+++ b/packages/i18n/src/core/es.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "Acrílico",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Cristal",
- "setting_variant_windowmaterial_solid": "Sólido"
+ "setting_variant_windowmaterial_solid": "Sólido",
+ "apply": "Aplicar",
+ "cosmetic_wardrobe": "Vestuario",
+ "cosmetic_wardrobe_hint": "Lo que llevas en tu avatar, en tu tarjeta de perfil y en tu nombre.",
+ "cosmetic_kind_avatar_decoration": "Decoración de avatar",
+ "cosmetic_kind_profile_frame": "Marco de perfil",
+ "cosmetic_kind_nickname_style": "Estilo del nombre",
+ "cosmetic_kind_option_font": "Fuentes",
+ "cosmetic_kind_option_text_effect": "Efectos del nombre",
+ "cosmetic_clear": "Quitar",
+ "cosmetic_locked": "Bloqueado",
+ "cosmetic_locked_ultima": "Con Ultima",
+ "cosmetic_locked_grant": "Aún no es tuyo",
+ "cosmetic_available_until": "Disponible hasta {date}",
+ "cosmetic_surface_card": "Tarjeta de perfil",
+ "cosmetic_surface_avatar": "Avatar",
+ "cosmetic_surface_message": "En el chat",
+ "cosmetic_surface_member_row": "Lista de miembros",
+ "cosmetic_equip_failed": "No se pudo poner",
+ "cosmetic_error_network": "No se pudo conectar con el servidor. Inténtalo de nuevo.",
+ "cosmetic_error_not_found": "Ese objeto ya no existe.",
+ "cosmetic_error_unknown_kind": "Este tipo de cosmético no es compatible.",
+ "cosmetic_error_kind_disabled": "Este tipo de cosmético está desactivado ahora mismo.",
+ "cosmetic_error_item_unavailable": "Ese objeto ya no está disponible.",
+ "cosmetic_error_not_owned": "Todavía no tienes ese objeto.",
+ "cosmetic_error_choice_invalid": "Una de las opciones elegidas no sirve aquí.",
+ "cosmetic_error_value_out_of_range": "Uno de los ajustes está fuera de rango."
}
diff --git a/packages/i18n/src/core/jp.json b/packages/i18n/src/core/jp.json
index 679ac620..e4c20128 100644
--- a/packages/i18n/src/core/jp.json
+++ b/packages/i18n/src/core/jp.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "アクリル",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "ガラス",
- "setting_variant_windowmaterial_solid": "単色"
+ "setting_variant_windowmaterial_solid": "単色",
+ "apply": "適用",
+ "cosmetic_wardrobe": "ワードローブ",
+ "cosmetic_wardrobe_hint": "アバター、プロフィールカード、名前に身につけるもの。",
+ "cosmetic_kind_avatar_decoration": "アバターデコレーション",
+ "cosmetic_kind_profile_frame": "プロフィールフレーム",
+ "cosmetic_kind_nickname_style": "名前のスタイル",
+ "cosmetic_kind_option_font": "フォント",
+ "cosmetic_kind_option_text_effect": "名前のエフェクト",
+ "cosmetic_clear": "外す",
+ "cosmetic_locked": "ロック中",
+ "cosmetic_locked_ultima": "Ultima で利用可能",
+ "cosmetic_locked_grant": "まだ持っていません",
+ "cosmetic_available_until": "{date} まで利用可能",
+ "cosmetic_surface_card": "プロフィールカード",
+ "cosmetic_surface_avatar": "アバター",
+ "cosmetic_surface_message": "チャット",
+ "cosmetic_surface_member_row": "メンバーリスト",
+ "cosmetic_equip_failed": "身につけられませんでした",
+ "cosmetic_error_network": "サーバーに接続できませんでした。もう一度お試しください。",
+ "cosmetic_error_not_found": "このアイテムはもう存在しません。",
+ "cosmetic_error_unknown_kind": "この種類のコスメティックには対応していません。",
+ "cosmetic_error_kind_disabled": "この種類のコスメティックは現在オフになっています。",
+ "cosmetic_error_item_unavailable": "このアイテムはもう利用できません。",
+ "cosmetic_error_not_owned": "このアイテムはまだ持っていません。",
+ "cosmetic_error_choice_invalid": "選んだオプションのひとつはここでは使えません。",
+ "cosmetic_error_value_out_of_range": "設定のひとつが範囲外です。"
}
diff --git a/packages/i18n/src/core/kk.json b/packages/i18n/src/core/kk.json
index 2bfa463b..06efeae9 100644
--- a/packages/i18n/src/core/kk.json
+++ b/packages/i18n/src/core/kk.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "Акрил",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Шыны",
- "setting_variant_windowmaterial_solid": "Тұтас"
+ "setting_variant_windowmaterial_solid": "Тұтас",
+ "apply": "Қолдану",
+ "cosmetic_wardrobe": "Гардероб",
+ "cosmetic_wardrobe_hint": "Аватарыңызда, профиль картаңызда және атыңызда не киетініңіз.",
+ "cosmetic_kind_avatar_decoration": "Аватар әшекейі",
+ "cosmetic_kind_profile_frame": "Профиль жиегі",
+ "cosmetic_kind_nickname_style": "Ат стилі",
+ "cosmetic_kind_option_font": "Қаріптер",
+ "cosmetic_kind_option_text_effect": "Ат эффектілері",
+ "cosmetic_clear": "Шешу",
+ "cosmetic_locked": "Құлыпталған",
+ "cosmetic_locked_ultima": "Ultima арқылы",
+ "cosmetic_locked_grant": "Әзірге сіздікі емес",
+ "cosmetic_available_until": "{date} дейін қолжетімді",
+ "cosmetic_surface_card": "Профиль картасы",
+ "cosmetic_surface_avatar": "Аватар",
+ "cosmetic_surface_message": "Чатта",
+ "cosmetic_surface_member_row": "Қатысушылар тізімі",
+ "cosmetic_equip_failed": "Кию мүмкін болмады",
+ "cosmetic_error_network": "Сервермен байланыс орнатылмады. Қайталап көріңіз.",
+ "cosmetic_error_not_found": "Бұл зат енді жоқ.",
+ "cosmetic_error_unknown_kind": "Косметиканың бұл түріне қолдау көрсетілмейді.",
+ "cosmetic_error_kind_disabled": "Косметиканың бұл түрі қазір өшірулі.",
+ "cosmetic_error_item_unavailable": "Бұл зат енді қолжетімсіз.",
+ "cosmetic_error_not_owned": "Сізде бұл зат әлі жоқ.",
+ "cosmetic_error_choice_invalid": "Таңдалған нұсқалардың бірі мұнда жарамайды.",
+ "cosmetic_error_value_out_of_range": "Баптаулардың бірі рұқсат етілген ауқымнан тыс."
}
diff --git a/packages/i18n/src/core/ko.json b/packages/i18n/src/core/ko.json
index 0b08cfc5..678a86d0 100644
--- a/packages/i18n/src/core/ko.json
+++ b/packages/i18n/src/core/ko.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "아크릴",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "유리",
- "setting_variant_windowmaterial_solid": "단색"
+ "setting_variant_windowmaterial_solid": "단색",
+ "apply": "적용",
+ "cosmetic_wardrobe": "옷장",
+ "cosmetic_wardrobe_hint": "아바타, 프로필 카드, 이름에 착용하는 것들입니다.",
+ "cosmetic_kind_avatar_decoration": "아바타 장식",
+ "cosmetic_kind_profile_frame": "프로필 테두리",
+ "cosmetic_kind_nickname_style": "이름 스타일",
+ "cosmetic_kind_option_font": "글꼴",
+ "cosmetic_kind_option_text_effect": "이름 효과",
+ "cosmetic_clear": "벗기",
+ "cosmetic_locked": "잠김",
+ "cosmetic_locked_ultima": "Ultima 전용",
+ "cosmetic_locked_grant": "아직 보유하지 않음",
+ "cosmetic_available_until": "{date}까지 이용 가능",
+ "cosmetic_surface_card": "프로필 카드",
+ "cosmetic_surface_avatar": "아바타",
+ "cosmetic_surface_message": "채팅",
+ "cosmetic_surface_member_row": "멤버 목록",
+ "cosmetic_equip_failed": "착용하지 못했습니다",
+ "cosmetic_error_network": "서버에 연결할 수 없습니다. 다시 시도해 주세요.",
+ "cosmetic_error_not_found": "더 이상 존재하지 않는 아이템입니다.",
+ "cosmetic_error_unknown_kind": "지원하지 않는 종류의 꾸미기 아이템입니다.",
+ "cosmetic_error_kind_disabled": "이 종류의 꾸미기 아이템은 현재 꺼져 있습니다.",
+ "cosmetic_error_item_unavailable": "더 이상 사용할 수 없는 아이템입니다.",
+ "cosmetic_error_not_owned": "아직 이 아이템을 가지고 있지 않습니다.",
+ "cosmetic_error_choice_invalid": "선택한 옵션 중 하나를 여기에서 사용할 수 없습니다.",
+ "cosmetic_error_value_out_of_range": "설정 값 중 하나가 허용 범위를 벗어났습니다."
}
diff --git a/packages/i18n/src/core/pl.json b/packages/i18n/src/core/pl.json
index 2d124519..606555f7 100644
--- a/packages/i18n/src/core/pl.json
+++ b/packages/i18n/src/core/pl.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "Akryl",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Szkło",
- "setting_variant_windowmaterial_solid": "Jednolity"
+ "setting_variant_windowmaterial_solid": "Jednolity",
+ "apply": "Zastosuj",
+ "cosmetic_wardrobe": "Garderoba",
+ "cosmetic_wardrobe_hint": "To, co nosisz na awatarze, na karcie profilu i przy nazwie.",
+ "cosmetic_kind_avatar_decoration": "Ozdoba awatara",
+ "cosmetic_kind_profile_frame": "Ramka profilu",
+ "cosmetic_kind_nickname_style": "Styl nazwy",
+ "cosmetic_kind_option_font": "Czcionki",
+ "cosmetic_kind_option_text_effect": "Efekty nazwy",
+ "cosmetic_clear": "Zdejmij",
+ "cosmetic_locked": "Zablokowane",
+ "cosmetic_locked_ultima": "Z Ultima",
+ "cosmetic_locked_grant": "Jeszcze nie twoje",
+ "cosmetic_available_until": "Dostępne do {date}",
+ "cosmetic_surface_card": "Karta profilu",
+ "cosmetic_surface_avatar": "Awatar",
+ "cosmetic_surface_message": "Na czacie",
+ "cosmetic_surface_member_row": "Lista członków",
+ "cosmetic_equip_failed": "Nie udało się założyć",
+ "cosmetic_error_network": "Nie udało się połączyć z serwerem. Spróbuj ponownie.",
+ "cosmetic_error_not_found": "Ten przedmiot już nie istnieje.",
+ "cosmetic_error_unknown_kind": "Ten rodzaj kosmetyku nie jest obsługiwany.",
+ "cosmetic_error_kind_disabled": "Ten rodzaj kosmetyku jest teraz wyłączony.",
+ "cosmetic_error_item_unavailable": "Ten przedmiot nie jest już dostępny.",
+ "cosmetic_error_not_owned": "Nie masz jeszcze tego przedmiotu.",
+ "cosmetic_error_choice_invalid": "Jedna z wybranych opcji tu nie pasuje.",
+ "cosmetic_error_value_out_of_range": "Jedno z ustawień jest poza zakresem."
}
diff --git a/packages/i18n/src/core/ru.json b/packages/i18n/src/core/ru.json
index 47804f67..433af7e9 100644
--- a/packages/i18n/src/core/ru.json
+++ b/packages/i18n/src/core/ru.json
@@ -7,6 +7,7 @@
"create_new_server": "Создать новый сервер",
"announcement": "Объявление",
"privacy_settings": "Настройки приватности",
+ "apply": "Применить",
"cancel": "Отмена",
"voice": "Голос",
"start_video": "Начать видео",
@@ -2546,5 +2547,30 @@
"setting_variant_windowmaterial_acrylic": "Акрил",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Стекло",
- "setting_variant_windowmaterial_solid": "Сплошной"
+ "setting_variant_windowmaterial_solid": "Сплошной",
+ "cosmetic_wardrobe": "Гардероб",
+ "cosmetic_wardrobe_hint": "Что вы носите на аватаре, в карточке профиля и в имени.",
+ "cosmetic_kind_avatar_decoration": "Украшение аватара",
+ "cosmetic_kind_profile_frame": "Рамка профиля",
+ "cosmetic_kind_nickname_style": "Стиль имени",
+ "cosmetic_kind_option_font": "Шрифты",
+ "cosmetic_kind_option_text_effect": "Эффекты имени",
+ "cosmetic_clear": "Убрать",
+ "cosmetic_locked": "Недоступно",
+ "cosmetic_locked_ultima": "С Ultima",
+ "cosmetic_locked_grant": "Пока не ваше",
+ "cosmetic_available_until": "Доступно до {date}",
+ "cosmetic_surface_card": "Карточка профиля",
+ "cosmetic_surface_avatar": "Аватар",
+ "cosmetic_surface_message": "В чате",
+ "cosmetic_surface_member_row": "Список участников",
+ "cosmetic_equip_failed": "Не удалось надеть",
+ "cosmetic_error_network": "Не удалось связаться с сервером. Попробуйте ещё раз.",
+ "cosmetic_error_not_found": "Этого предмета больше нет.",
+ "cosmetic_error_unknown_kind": "Этот вид украшений не поддерживается.",
+ "cosmetic_error_kind_disabled": "Этот вид украшений сейчас отключён.",
+ "cosmetic_error_item_unavailable": "Этот предмет больше недоступен.",
+ "cosmetic_error_not_owned": "У вас пока нет этого предмета.",
+ "cosmetic_error_choice_invalid": "Один из выбранных вариантов здесь не подходит.",
+ "cosmetic_error_value_out_of_range": "Одна из настроек вне допустимого диапазона."
}
diff --git a/packages/i18n/src/core/ru_pt.json b/packages/i18n/src/core/ru_pt.json
index dd31a0bb..d7325a5c 100644
--- a/packages/i18n/src/core/ru_pt.json
+++ b/packages/i18n/src/core/ru_pt.json
@@ -2529,5 +2529,31 @@
"setting_variant_windowmaterial_acrylic": "Акрил",
"setting_variant_windowmaterial_mica": "Слюда",
"setting_variant_windowmaterial_glass": "Иллюминатор",
- "setting_variant_windowmaterial_solid": "Глухой борт"
+ "setting_variant_windowmaterial_solid": "Глухой борт",
+ "apply": "Принять на борт",
+ "cosmetic_wardrobe": "Сундук с нарядами",
+ "cosmetic_wardrobe_hint": "Что ты носишь на аватаре, на карточке и на имени, юнга.",
+ "cosmetic_kind_avatar_decoration": "Украшение аватара",
+ "cosmetic_kind_profile_frame": "Рамка профиля",
+ "cosmetic_kind_nickname_style": "Стиль имени",
+ "cosmetic_kind_option_font": "Шрифты",
+ "cosmetic_kind_option_text_effect": "Эффекты имени",
+ "cosmetic_clear": "Снять, к акулам",
+ "cosmetic_locked": "Под замком",
+ "cosmetic_locked_ultima": "С Ultima",
+ "cosmetic_locked_grant": "Пока не твоя добыча",
+ "cosmetic_available_until": "Доступно до {date}",
+ "cosmetic_surface_card": "Карточка профиля",
+ "cosmetic_surface_avatar": "Аватар",
+ "cosmetic_surface_message": "В кубрике",
+ "cosmetic_surface_member_row": "Судовая роль",
+ "cosmetic_equip_failed": "Надеть не вышло",
+ "cosmetic_error_network": "Сервер не отвечает, штиль. Попробуй ещё раз.",
+ "cosmetic_error_not_found": "Этой вещицы больше нет — ушла на дно.",
+ "cosmetic_error_unknown_kind": "Такой вид украшений здесь не водится.",
+ "cosmetic_error_kind_disabled": "Этот вид украшений сейчас убран в трюм.",
+ "cosmetic_error_item_unavailable": "Эта вещица больше недоступна.",
+ "cosmetic_error_not_owned": "Эта добыча пока не твоя.",
+ "cosmetic_error_choice_invalid": "Один из выбранных вариантов тут не годится.",
+ "cosmetic_error_value_out_of_range": "Одна из настроек за бортом допустимого."
}
diff --git a/packages/i18n/src/core/uz.json b/packages/i18n/src/core/uz.json
index 1781367e..fb1e14b8 100644
--- a/packages/i18n/src/core/uz.json
+++ b/packages/i18n/src/core/uz.json
@@ -2504,5 +2504,31 @@
"setting_variant_windowmaterial_acrylic": "Akril",
"setting_variant_windowmaterial_mica": "Mica",
"setting_variant_windowmaterial_glass": "Shisha",
- "setting_variant_windowmaterial_solid": "Yaxlit"
+ "setting_variant_windowmaterial_solid": "Yaxlit",
+ "apply": "Qoʻllash",
+ "cosmetic_wardrobe": "Garderob",
+ "cosmetic_wardrobe_hint": "Avatar, profil kartasi va ismingizda nima kiyganingiz.",
+ "cosmetic_kind_avatar_decoration": "Avatar bezagi",
+ "cosmetic_kind_profile_frame": "Profil ramkasi",
+ "cosmetic_kind_nickname_style": "Ism uslubi",
+ "cosmetic_kind_option_font": "Shriftlar",
+ "cosmetic_kind_option_text_effect": "Ism effektlari",
+ "cosmetic_clear": "Yechish",
+ "cosmetic_locked": "Qulflangan",
+ "cosmetic_locked_ultima": "Ultima bilan",
+ "cosmetic_locked_grant": "Hali sizniki emas",
+ "cosmetic_available_until": "{date} gacha mavjud",
+ "cosmetic_surface_card": "Profil kartasi",
+ "cosmetic_surface_avatar": "Avatar",
+ "cosmetic_surface_message": "Chatda",
+ "cosmetic_surface_member_row": "Aʼzolar roʻyxati",
+ "cosmetic_equip_failed": "Kiyib boʻlmadi",
+ "cosmetic_error_network": "Server bilan bogʻlanib boʻlmadi. Qayta urinib koʻring.",
+ "cosmetic_error_not_found": "Bu buyum endi mavjud emas.",
+ "cosmetic_error_unknown_kind": "Bu turdagi kosmetika qoʻllab-quvvatlanmaydi.",
+ "cosmetic_error_kind_disabled": "Bu turdagi kosmetika hozir oʻchirilgan.",
+ "cosmetic_error_item_unavailable": "Bu buyum endi mavjud emas.",
+ "cosmetic_error_not_owned": "Sizda hali bu buyum yoʻq.",
+ "cosmetic_error_choice_invalid": "Tanlangan variantlardan biri bu yerga mos kelmaydi.",
+ "cosmetic_error_value_out_of_range": "Sozlamalardan biri ruxsat etilgan oraliqdan tashqarida."
}
diff --git a/src/components/SettingsWindow.vue b/src/components/SettingsWindow.vue
index 9ceae24d..73f242db 100644
--- a/src/components/SettingsWindow.vue
+++ b/src/components/SettingsWindow.vue
@@ -73,6 +73,7 @@ const TransactionSettings = defineAsyncComponent(() => import("./settings/Transa
const ConnectionsSettings = defineAsyncComponent(() => import("./settings/ConnectionsSettings.vue"));
import { useConfigStore } from "@/store/ui/configStore";
import { useFeatureFlags } from "@/store/features/featureFlagsStore";
+import { isEscapeLayerOpen } from "@/lib/modalLayers";
// Stores
const { t } = useLocale();
@@ -165,9 +166,16 @@ const changeCategory = (categoryId: string) => {
};
const handleEscape = (event: KeyboardEvent) => {
- if (event.key === "Escape" && windows.settingsOpen) {
- closeSettings();
+ if (event.key !== "Escape" || !windows.settingsOpen) {
+ return;
}
+
+ // A dialog that claimed Escape owns it; one press must not close both.
+ if (isEscapeLayerOpen()) {
+ return;
+ }
+
+ closeSettings();
};
// Lifecycle
diff --git a/src/components/settings/ProfileSettings.vue b/src/components/settings/ProfileSettings.vue
index a8d04f8f..c7fc92a1 100644
--- a/src/components/settings/ProfileSettings.vue
+++ b/src/components/settings/ProfileSettings.vue
@@ -212,6 +212,13 @@
+
+
+
@@ -829,6 +836,7 @@
+
+
+
+
diff --git a/src/components/settings/cosmetics/CosmeticPickerDialog.vue b/src/components/settings/cosmetics/CosmeticPickerDialog.vue
new file mode 100644
index 00000000..4aa89e4f
--- /dev/null
+++ b/src/components/settings/cosmetics/CosmeticPickerDialog.vue
@@ -0,0 +1,904 @@
+
+
+
+
+
+
+
+ {{ t(kind.labelKey) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ nameOf(item) }}
+
+
+
+
+
+
+
+
+ {{ t(lock.key) }}
+
+ {{ t("ultima_get") }}
+
+
+
+
+
+ {{ t("cosmetic_available_until", { date: until }) }}
+
+
+
+
+
+
+
+
+
+ {{ t("cosmetic_clear") }}
+
+
+
+ {{ t("cancel") }}
+
+ {{ t("apply") }}
+
+
+
+
+
+
+
+
diff --git a/src/components/settings/cosmetics/CosmeticRowPreview.vue b/src/components/settings/cosmetics/CosmeticRowPreview.vue
new file mode 100644
index 00000000..2ae5fc3b
--- /dev/null
+++ b/src/components/settings/cosmetics/CosmeticRowPreview.vue
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/settings/cosmetics/CosmeticsPanel.vue b/src/components/settings/cosmetics/CosmeticsPanel.vue
new file mode 100644
index 00000000..a3e5c6dc
--- /dev/null
+++ b/src/components/settings/cosmetics/CosmeticsPanel.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+
{{ t("cosmetic_wardrobe") }}
+
+
{{ t("cosmetic_wardrobe_hint") }}
+
+
+
+
+
+
+
+
+ {{ t(kind.labelKey) }}
+ {{ wornLabel(kind.key) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/settings/cosmetics/cosmeticLock.ts b/src/components/settings/cosmetics/cosmeticLock.ts
new file mode 100644
index 00000000..dd5ba816
--- /dev/null
+++ b/src/components/settings/cosmetics/cosmeticLock.ts
@@ -0,0 +1,38 @@
+import type { CatalogueCosmetic } from "@argon/glue";
+
+/** Why a catalogue row cannot be put on: it comes with Ultima, or it is handed out rather than taken. */
+export type CosmeticLockReason = "ultima" | "granted";
+
+/** `wearable` is the store's `mayWear`: free, or owned (Ultima rows arrive as owned via the subscription). */
+export function lockReasonOf(item: CatalogueCosmetic, wearable: boolean): CosmeticLockReason | null {
+ if (wearable) {
+ return null;
+ }
+
+ return item.ultima ? "ultima" : "granted";
+}
+
+export function availableUntilOf(item: Pick
): Date | null {
+ const until = item.availableUntil;
+
+ if (!until || typeof until.toDate !== "function") {
+ return null;
+ }
+
+ const date = until.toDate();
+
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+
+ return date;
+}
+
+/** A row's name as assistive technology hears it: a locked row says so, since its padlock is only drawn. */
+export function lockedAccessibleName(name: string, wearable: boolean, lockedWord: string): string {
+ if (wearable) {
+ return name;
+ }
+
+ return `${name}, ${lockedWord}`;
+}
diff --git a/src/components/settings/cosmetics/rovingRadio.ts b/src/components/settings/cosmetics/rovingRadio.ts
new file mode 100644
index 00000000..470fe39b
--- /dev/null
+++ b/src/components/settings/cosmetics/rovingRadio.ts
@@ -0,0 +1,44 @@
+const RADIO_SELECTOR = '[role="radio"]';
+
+const GROUP_SELECTOR = '[role="radiogroup"]';
+
+/** Where an arrow, Home or End moves from `from` in a group of `count`, wrapping at the ends; null for any other key. */
+export function radioStepTarget(event: KeyboardEvent, from: number, count: number): number | null {
+ if (count === 0 || event.altKey || event.ctrlKey || event.metaKey) {
+ return null;
+ }
+
+ switch (event.key) {
+ case "ArrowRight":
+ case "ArrowDown":
+ return (from + 1) % count;
+
+ case "ArrowLeft":
+ case "ArrowUp":
+ return (from - 1 + count) % count;
+
+ case "Home":
+ return 0;
+
+ case "End":
+ return count - 1;
+
+ default:
+ return null;
+ }
+}
+
+/** Focus follows the choice: the radio at `index` of the group `radio` belongs to. */
+export function focusRadioAt(radio: Element, index: number): void {
+ const group = radio.closest(GROUP_SELECTOR);
+
+ if (group === null) {
+ return;
+ }
+
+ const target = group.querySelectorAll(RADIO_SELECTOR)[index];
+
+ if (target !== undefined) {
+ target.focus();
+ }
+}
diff --git a/src/components/settings/cosmetics/wardrobeChange.ts b/src/components/settings/cosmetics/wardrobeChange.ts
new file mode 100644
index 00000000..05555bb5
--- /dev/null
+++ b/src/components/settings/cosmetics/wardrobeChange.ts
@@ -0,0 +1,53 @@
+import {
+ CosmeticError,
+ type IWornCosmetic,
+} from "@argon/glue";
+
+import { toWornItem } from "@/cosmetics/wire";
+
+/** What one Apply in the wardrobe asks the server for: put one look on, or take a kind off. */
+export type WardrobeChange =
+ | {
+ readonly action: "equip";
+ readonly kindKey: string;
+ readonly worn: IWornCosmetic;
+ }
+ | {
+ readonly action: "unequip";
+ readonly kindKey: string;
+ };
+
+export function equipChange(kindKey: string, worn: IWornCosmetic): WardrobeChange {
+ return { action: "equip", kindKey, worn };
+}
+
+export function unequipChange(kindKey: string): WardrobeChange {
+ return { action: "unequip", kindKey };
+}
+
+/** A kind worn as one catalogue row: a row replaces whatever is on, no row takes the kind off. */
+export function itemChange(kindKey: string, itemId: string | null): WardrobeChange {
+ if (itemId === null) {
+ return unequipChange(kindKey);
+ }
+
+ return equipChange(kindKey, toWornItem(itemId));
+}
+
+/** The entry a change leaves on the profile for its kind, for drawing a draft before it is applied. */
+export function wornAfter(change: WardrobeChange): IWornCosmetic | null {
+ if (change.action === "equip") {
+ return change.worn;
+ }
+
+ return null;
+}
+
+/** Taking off a kind that was already off is the state the wearer asked for, not a failure. */
+export function refusalOf(change: WardrobeChange, refusal: CosmeticError | null): CosmeticError | null {
+ if (change.action === "unequip" && refusal === CosmeticError.NOT_FOUND) {
+ return null;
+ }
+
+ return refusal;
+}
diff --git a/src/cosmetics/CosmeticLookPreview.vue b/src/cosmetics/CosmeticLookPreview.vue
new file mode 100644
index 00000000..723db5ab
--- /dev/null
+++ b/src/cosmetics/CosmeticLookPreview.vue
@@ -0,0 +1,218 @@
+
+
+
+
+
+
+
+
+ {{ t("cosmetic_surface_card") }}
+
+
+
+
+ {{ t("cosmetic_surface_avatar") }}
+
+
+
+
+ {{ t("cosmetic_surface_message") }}
+
+
+
+
+ {{ t("cosmetic_surface_member_row") }}
+
+
+
+
+
+
diff --git a/src/cosmetics/CosmeticPreview.vue b/src/cosmetics/CosmeticPreview.vue
new file mode 100644
index 00000000..fe1e0ae6
--- /dev/null
+++ b/src/cosmetics/CosmeticPreview.vue
@@ -0,0 +1,422 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/cosmetics/authoredCard.ts b/src/cosmetics/authoredCard.ts
new file mode 100644
index 00000000..10b4850f
--- /dev/null
+++ b/src/cosmetics/authoredCard.ts
@@ -0,0 +1,2 @@
+/** The card width a cosmetic's pixels are authored against; a smaller drawing scales from it. */
+export const AUTHORED_CARD_WIDTH = 384;
diff --git a/src/cosmetics/preview/leadingGraphemes.ts b/src/cosmetics/preview/leadingGraphemes.ts
new file mode 100644
index 00000000..1a301827
--- /dev/null
+++ b/src/cosmetics/preview/leadingGraphemes.ts
@@ -0,0 +1,18 @@
+const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
+
+/** The first `count` user-perceived characters, so a shortened name never ends in half an emoji. */
+export function leadingGraphemes(text: string, count: number): string {
+ let taken = "";
+ let takenCount = 0;
+
+ for (const { segment } of graphemes.segment(text)) {
+ if (takenCount >= count) {
+ break;
+ }
+
+ taken += segment;
+ takenCount++;
+ }
+
+ return taken;
+}
diff --git a/src/cosmetics/preview/previewUserId.ts b/src/cosmetics/preview/previewUserId.ts
new file mode 100644
index 00000000..f435173b
--- /dev/null
+++ b/src/cosmetics/preview/previewUserId.ts
@@ -0,0 +1,2 @@
+/** The one person in the preview. Fixed, so a re-render patches the card rather than remounting it. */
+export const PREVIEW_USER_ID = "00000000-0000-0000-0000-0000000c0de0";
diff --git a/src/cosmetics/preview/stages/AvatarStage.vue b/src/cosmetics/preview/stages/AvatarStage.vue
new file mode 100644
index 00000000..fa895a55
--- /dev/null
+++ b/src/cosmetics/preview/stages/AvatarStage.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
diff --git a/src/cosmetics/preview/stages/MemberRowStage.vue b/src/cosmetics/preview/stages/MemberRowStage.vue
new file mode 100644
index 00000000..940046f8
--- /dev/null
+++ b/src/cosmetics/preview/stages/MemberRowStage.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+ {{ displayName }}
+
+ Playing something
+
+
+
+
+
+
+ {{ neighbour.slice(0, 1) }}
+
+
+ {{ neighbour }}
+
+
+
+
+
+
diff --git a/src/cosmetics/preview/stages/MessageStage.vue b/src/cosmetics/preview/stages/MessageStage.vue
new file mode 100644
index 00000000..e5249d1a
--- /dev/null
+++ b/src/cosmetics/preview/stages/MessageStage.vue
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayName }}
+
+ 12:07
+
+
so is it shipping today or are we pretending again
+
+
+
+
+
C
+
+
+ Casper
+ 12:08
+
+
shipping. probably.
+
+
+
+
+
+
diff --git a/src/lib/cosmeticText.ts b/src/lib/cosmeticText.ts
new file mode 100644
index 00000000..2cd43751
--- /dev/null
+++ b/src/lib/cosmeticText.ts
@@ -0,0 +1,30 @@
+import type {
+ CatalogueCosmetic,
+ CosmeticText,
+} from "@argon/glue";
+
+const FALLBACK_LOCALE = "en";
+
+/**
+ * The catalogue carries every language an operator wrote, so a locale switch needs no refetch.
+ * The name key is the last resort: rendered as itself, it is the visible sign a row shipped unnamed.
+ */
+function writtenText(item: Pick, locale: string): CosmeticText | null {
+ const texts = item.text;
+
+ if (!texts || texts.length === 0) {
+ return null;
+ }
+
+ return texts.find(text => text.locale === locale)
+ ?? texts.find(text => text.locale === FALLBACK_LOCALE)
+ ?? null;
+}
+
+export function cosmeticName(item: Pick, locale: string): string {
+ return writtenText(item, locale)?.name ?? item.nameKey;
+}
+
+export function cosmeticDescription(item: Pick, locale: string): string | null {
+ return writtenText(item, locale)?.description ?? null;
+}
diff --git a/src/lib/modalLayers.ts b/src/lib/modalLayers.ts
new file mode 100644
index 00000000..05e13a32
--- /dev/null
+++ b/src/lib/modalLayers.ts
@@ -0,0 +1,110 @@
+import {
+ onBeforeUnmount,
+ onMounted,
+ watch,
+} from "vue";
+
+interface EscapeLayer {
+ readonly isOpen: () => boolean;
+ readonly dismiss: () => void;
+}
+
+/** Ordered by when each layer last opened, so the last open one is the one on top. */
+const layers: EscapeLayer[] = [];
+
+function topOpenLayer(): EscapeLayer | null {
+ for (let index = layers.length - 1; index >= 0; index--) {
+ const layer = layers[index];
+
+ if (layer.isOpen()) {
+ return layer;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Whether a layer registered through `useEscapeDismiss` is open, for window-level Escape handlers that
+ * must not close their window underneath it. Dialogs that never registered keep the key's old meaning.
+ */
+export function isEscapeLayerOpen(): boolean {
+ return topOpenLayer() !== null;
+}
+
+/**
+ * The settings windows are vaul drawers with `dismissible: false`, whose own Escape handler calls
+ * `preventDefault()` first, and reka only dismisses when the event is not prevented. A capture
+ * listener on `window` is the one position ahead of both; it stops the event so one press closes
+ * exactly one thing.
+ */
+function onKeyDown(event: KeyboardEvent): void {
+ if (event.key !== "Escape" || event.defaultPrevented) {
+ return;
+ }
+
+ const top = topOpenLayer();
+
+ if (top === null) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ top.dismiss();
+}
+
+function remove(layer: EscapeLayer): void {
+ const index = layers.indexOf(layer);
+
+ if (index !== -1) {
+ layers.splice(index, 1);
+ }
+}
+
+function register(layer: EscapeLayer): void {
+ if (layers.length === 0) {
+ window.addEventListener("keydown", onKeyDown, true);
+ }
+
+ layers.push(layer);
+}
+
+function unregister(layer: EscapeLayer): void {
+ remove(layer);
+
+ if (layers.length === 0) {
+ window.removeEventListener("keydown", onKeyDown, true);
+ }
+}
+
+function raise(layer: EscapeLayer): void {
+ if (!layers.includes(layer)) {
+ return;
+ }
+
+ remove(layer);
+ layers.push(layer);
+}
+
+/**
+ * Closes a dialog on Escape ahead of everything else that wants the key. Only the topmost open layer
+ * takes a press, and a press something earlier already claimed is left alone.
+ */
+export function useEscapeDismiss(isOpen: () => boolean, dismiss: () => void): void {
+ const layer: EscapeLayer = { isOpen, dismiss };
+
+ watch(isOpen, open => {
+ if (open) {
+ raise(layer);
+ }
+ });
+
+ onMounted(() => {
+ register(layer);
+ });
+
+ onBeforeUnmount(() => {
+ unregister(layer);
+ });
+}
diff --git a/src/lib/refusals.ts b/src/lib/refusals.ts
index c49aa18d..71a103ab 100644
--- a/src/lib/refusals.ts
+++ b/src/lib/refusals.ts
@@ -1,6 +1,7 @@
import {
ArchetypeError,
ChannelLayoutError,
+ CosmeticError,
EmailChangeError,
ExpressionError,
RegistrationError,
@@ -62,6 +63,16 @@ const EXPRESSION: Partial> = {
[ExpressionError.RATE_LIMITED]: "expression_error_rate_limited",
};
+const COSMETIC: Partial> = {
+ [CosmeticError.NOT_FOUND]: "cosmetic_error_not_found",
+ [CosmeticError.UNKNOWN_KIND]: "cosmetic_error_unknown_kind",
+ [CosmeticError.KIND_DISABLED]: "cosmetic_error_kind_disabled",
+ [CosmeticError.ITEM_UNAVAILABLE]: "cosmetic_error_item_unavailable",
+ [CosmeticError.NOT_OWNED]: "cosmetic_error_not_owned",
+ [CosmeticError.CHOICE_INVALID]: "cosmetic_error_choice_invalid",
+ [CosmeticError.VALUE_OUT_OF_RANGE]: "cosmetic_error_value_out_of_range",
+};
+
const UPDATE_ME: Partial> = {
[UpdateMeError.INVALID_STATUS_EMOJI]: "status_emoji_invalid",
};
@@ -133,6 +144,10 @@ export function expressionErrorKey(error: ExpressionError): string {
return EXPRESSION[error] ?? "expression_error_unknown";
}
+export function cosmeticErrorKey(error: CosmeticError): string {
+ return COSMETIC[error] ?? "cosmetic_equip_failed";
+}
+
export function updateMeErrorKey(error: UpdateMeError): string {
return UPDATE_ME[error] ?? "profile_update_failed";
}
diff --git a/test/components/CosmeticPickerDialog.test.ts b/test/components/CosmeticPickerDialog.test.ts
new file mode 100644
index 00000000..0989fa60
--- /dev/null
+++ b/test/components/CosmeticPickerDialog.test.ts
@@ -0,0 +1,488 @@
+/**
+ * The wardrobe picker: a draft until Apply, which emits exactly what goes over the wire — a WornItem
+ * for a catalogue kind — and nothing at all while the draft holds a row this account may not wear.
+ * Taking a kind off is a draft too, sent as an unequip.
+ */
+
+import {
+ beforeEach,
+ describe,
+ expect,
+ test,
+ vi,
+} from "vitest";
+import {
+ createPinia,
+ setActivePinia,
+} from "pinia";
+import {
+ flushPromises,
+ mount,
+} from "@vue/test-utils";
+
+const h = vi.hoisted(() => ({
+ getCatalogue: vi.fn(),
+ getMyCosmetics: vi.fn(),
+ me: {
+ me: { userId: "me", displayName: "Omni", username: "omni", avatarFileId: null },
+ meProfile: null as unknown,
+ isPremium: false,
+ },
+}));
+
+vi.mock("@argon/core", () => ({
+ logger: { log() {}, debug() {}, info() {}, warn() {}, error() {} },
+}));
+vi.mock("@/lib/telemetry/metrics", () => ({
+ metrics: { count() {} },
+ errorKind: () => "network",
+}));
+vi.mock("@/store/system/apiStore", () => ({
+ useApi: () => ({
+ cosmeticsInteraction: {
+ GetCatalogue: h.getCatalogue,
+ GetMyCosmetics: h.getMyCosmetics,
+ Equip: vi.fn(),
+ Unequip: vi.fn(),
+ },
+ }),
+}));
+vi.mock("@/store/auth/meStore", () => ({ useMe: () => h.me }));
+vi.mock("@/store/data/profileCacheStore", () => ({
+ useProfileCacheStore: () => ({ updateCosmetics: async () => {} }),
+}));
+vi.mock("@/store/system/localeStore", async () => {
+ const { ref } = await import("vue");
+ const currentLocale = ref("en");
+
+ return {
+ useLocale: () => ({
+ t: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key),
+ currentLocale,
+ }),
+ documentLanguage: (locale: string) => locale,
+ };
+});
+vi.mock("@/composables/useReducedMotion", async () => {
+ const { ref } = await import("vue");
+
+ return { reduceMotion: ref(false) };
+});
+vi.mock("@/store/system/fileStorage", () => ({
+ cdnUrl: (fileId: string) => `cdn:${fileId}`,
+ cdnCrossOrigin: () => undefined,
+}));
+vi.mock("@argon/ui/dialog", async () => {
+ const { defineComponent, h: renderNode } = await import("vue");
+ const pass = (name: string) => defineComponent({ name, setup: (_, { slots }) => () => renderNode("div", { class: name }, slots.default?.()) });
+
+ return {
+ Dialog: defineComponent({
+ props: { open: Boolean },
+ setup: (props, { slots }) => () => (props.open ? renderNode("div", { "data-dialog": "" }, slots.default?.()) : null),
+ }),
+ DialogContent: pass("DialogContent"),
+ DialogHeader: pass("DialogHeader"),
+ DialogTitle: pass("DialogTitle"),
+ };
+});
+vi.mock("@argon/ui/button", async () => {
+ const { defineComponent, h: renderNode } = await import("vue");
+
+ return {
+ Button: defineComponent({
+ inheritAttrs: false,
+ setup: (_, { attrs, slots }) => () => renderNode("button", { type: "button", ...attrs }, slots.default?.()),
+ }),
+ };
+});
+vi.mock("@/cosmetics/CosmeticLookPreview.vue", async () => {
+ const { defineComponent, h: renderNode } = await import("vue");
+
+ return {
+ default: defineComponent({
+ name: "CosmeticLookPreview",
+ props: { profile: { type: Object, default: null }, surfaces: { type: Array, default: () => [] } },
+ setup: () => () => renderNode("div", { class: "look-preview" }),
+ }),
+ };
+});
+vi.mock("@/components/settings/cosmetics/CosmeticCell.vue", async () => {
+ const { defineComponent, h: renderNode } = await import("vue");
+
+ return { default: defineComponent({ setup: () => () => renderNode("span") }) };
+});
+
+import {
+ FrameRing,
+ PayloadAvatarDecoration,
+ PayloadProfileFrame,
+ WornItem,
+ type ArgonUserProfile,
+ type CatalogueCosmetic,
+ type CosmeticCatalogue,
+ type ICosmeticPayload,
+ type IWornCosmetic,
+} from "@argon/glue";
+
+import type { WardrobeChange } from "@/components/settings/cosmetics/wardrobeChange";
+import CosmeticPickerDialog from "@/components/settings/cosmetics/CosmeticPickerDialog.vue";
+import { resolveKind } from "@/cosmetics/registry";
+import { runSessionReset } from "@/store/system/sessionLifecycle";
+import { useCosmeticsStore } from "@/store/features/cosmeticsStore";
+
+const FREE_FRAME = "aaaaaaaa-0000-0000-0000-000000000001";
+const LOCKED_FRAME = "aaaaaaaa-0000-0000-0000-000000000002";
+const WORN_FRAME = "aaaaaaaa-0000-0000-0000-000000000003";
+const DECORATION = "aaaaaaaa-0000-0000-0000-000000000004";
+
+const ring = new FrameRing(true, 100, null, null, 2, [0xff00ff00 | 0], 90, 0);
+
+function row(
+ cosmeticId: string,
+ kindKey: string,
+ slug: string,
+ payload: ICosmeticPayload,
+ overrides: Partial = {},
+): CatalogueCosmetic {
+ return {
+ cosmeticId,
+ kindKey,
+ slug,
+ nameKey: `cosmetic_${slug}`,
+ descriptionKey: null,
+ rarity: null,
+ version: 1,
+ payload,
+ assets: [],
+ availableUntil: null,
+ free: false,
+ ultima: false,
+ text: [{ locale: "en", name: slug, description: null }],
+ ...overrides,
+ };
+}
+
+const catalogue: CosmeticCatalogue = {
+ enabledKinds: ["avatar.decoration", "profile.frame"],
+ items: [
+ row(FREE_FRAME, "profile.frame", "vines", new PayloadProfileFrame([ring]), { free: true }),
+ row(LOCKED_FRAME, "profile.frame", "gilded", new PayloadProfileFrame([ring]), { ultima: true }),
+ row(WORN_FRAME, "profile.frame", "thorns", new PayloadProfileFrame([ring]), { free: true }),
+ row(DECORATION, "avatar.decoration", "halo", new PayloadAvatarDecoration(10, false), { free: true }),
+ ],
+};
+
+function wearing(...cosmetics: IWornCosmetic[]): ArgonUserProfile {
+ return { userId: "me", cosmetics } as unknown as ArgonUserProfile;
+}
+
+async function mountPicker(
+ kindKey: string,
+ profile: ArgonUserProfile,
+ extra: { busy?: boolean; canUpsell?: boolean } = {},
+) {
+ h.getCatalogue.mockResolvedValue(catalogue);
+ await useCosmeticsStore().ensureLoaded();
+
+ const wrapper = mount(CosmeticPickerDialog, {
+ props: {
+ open: true,
+ kind: resolveKind(kindKey)!,
+ profile,
+ items: useCosmeticsStore().optionsFor(kindKey),
+ busy: false,
+ ...extra,
+ },
+ attachTo: document.body,
+ });
+
+ await flushPromises();
+
+ return wrapper;
+}
+
+type Picker = Awaited>;
+
+function tileNamed(wrapper: Picker, name: string) {
+ const tile = wrapper.findAll(".picker-cell").find(candidate => candidate.text().includes(name));
+
+ if (tile === undefined) {
+ throw new Error(`no tile named ${name}`);
+ }
+
+ return tile;
+}
+
+/** Pointer events reach a disabled button in a browser, but test-utils will not trigger them on one. */
+async function pointAt(tile: ReturnType, type: "mouseenter" | "mouseleave"): Promise {
+ tile.element.dispatchEvent(new MouseEvent(type));
+ await flushPromises();
+}
+
+function buttonSaying(wrapper: Picker, key: string) {
+ return wrapper.findAll(".picker-actions button").find(button => button.text() === key);
+}
+
+function applyButton(wrapper: Picker) {
+ return buttonSaying(wrapper, "apply")!;
+}
+
+function applied(wrapper: Picker): WardrobeChange[] {
+ return (wrapper.emitted("apply") ?? []).map(args => args[0] as WardrobeChange);
+}
+
+beforeEach(async () => {
+ await runSessionReset();
+ setActivePinia(createPinia());
+ h.getCatalogue.mockReset();
+ h.getMyCosmetics.mockReset();
+ h.getMyCosmetics.mockResolvedValue({ owned: [] });
+ document.body.innerHTML = "";
+});
+
+describe("catalogue kinds", () => {
+ test("only rows of the kind are offered, and the worn one opens selected", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME.toUpperCase())));
+
+ expect(wrapper.findAll(".picker-cell")).toHaveLength(3);
+ expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true");
+ expect(tileNamed(wrapper, "thorns").classes()).toContain("picker-cell--active");
+ expect(applyButton(wrapper).attributes("disabled")).toBeDefined();
+
+ wrapper.unmount();
+ });
+
+ test("apply sends the chosen row as a WornItem, and leaves closing to whoever sends it", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME)));
+
+ await tileNamed(wrapper, "vines").trigger("click");
+ await applyButton(wrapper).trigger("click");
+
+ const [change] = applied(wrapper);
+
+ expect(change.action).toBe("equip");
+ expect(change.kindKey).toBe("profile.frame");
+ expect(change.action === "equip" && change.worn).toBeInstanceOf(WornItem);
+ expect(change.action === "equip" && (change.worn as WornItem).itemId).toBe(FREE_FRAME);
+ expect(wrapper.emitted("update:open")).toBeUndefined();
+
+ wrapper.unmount();
+ });
+
+ test("a locked row cannot be chosen, says so in its name, and its padlock is explained under the list", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing());
+ const gilded = tileNamed(wrapper, "gilded");
+
+ expect(gilded.attributes("disabled")).toBeDefined();
+ expect(gilded.classes()).toContain("picker-cell--locked");
+ expect(gilded.attributes("aria-label")).toBe("gilded, cosmetic_locked");
+ expect(wrapper.get(".picker-caption").text()).toBe("");
+
+ await pointAt(gilded, "mouseenter");
+
+ expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_locked_ultima");
+ expect(wrapper.find(".picker-upsell").exists()).toBe(false);
+
+ await pointAt(gilded, "mouseleave");
+
+ expect(wrapper.get(".picker-caption").text()).toBe("");
+
+ wrapper.unmount();
+ });
+
+ test("an Ultima padlock offers Ultima when the account can get it", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(), { canUpsell: true });
+
+ await pointAt(tileNamed(wrapper, "gilded"), "mouseenter");
+ await wrapper.get(".picker-upsell").trigger("click");
+
+ expect(wrapper.emitted("upsell")).toHaveLength(1);
+
+ wrapper.unmount();
+ });
+
+ test("a worn row this account may no longer wear keeps explaining itself, whatever is pointed at", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(LOCKED_FRAME)));
+
+ expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_locked_ultima");
+
+ await tileNamed(wrapper, "vines").trigger("mouseenter");
+
+ expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_locked_ultima");
+
+ wrapper.unmount();
+ });
+
+ test("an offer that ends says until when", async () => {
+ const ending = { toDate: () => new Date("2026-12-31T00:00:00Z") } as unknown as CatalogueCosmetic["availableUntil"];
+
+ catalogue.items[0] = row(FREE_FRAME, "profile.frame", "vines", new PayloadProfileFrame([ring]), { free: true, availableUntil: ending });
+
+ try {
+ const wrapper = await mountPicker("profile.frame", wearing());
+
+ await tileNamed(wrapper, "vines").trigger("mouseenter");
+
+ expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_available_until");
+
+ wrapper.unmount();
+ } finally {
+ catalogue.items[0] = row(FREE_FRAME, "profile.frame", "vines", new PayloadProfileFrame([ring]), { free: true });
+ }
+ });
+
+ test("taking a worn kind off is a draft until Apply, which sends the unequip", async () => {
+ const wrapper = await mountPicker("avatar.decoration", wearing(new WornItem(DECORATION)));
+
+ await buttonSaying(wrapper, "cosmetic_clear")!.trigger("click");
+
+ expect(applied(wrapper)).toEqual([]);
+ expect(buttonSaying(wrapper, "cosmetic_clear")).toBeUndefined();
+ expect(tileNamed(wrapper, "halo").attributes("aria-checked")).toBe("false");
+
+ await applyButton(wrapper).trigger("click");
+
+ expect(applied(wrapper)).toEqual([{ action: "unequip", kindKey: "avatar.decoration" }]);
+
+ wrapper.unmount();
+ });
+
+ test("while a change is on its way nothing can be applied, chosen, taken off or dismissed", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME)), { busy: true });
+
+ expect(applyButton(wrapper).attributes("disabled")).toBeDefined();
+ expect(buttonSaying(wrapper, "cosmetic_clear")!.attributes("disabled")).toBeDefined();
+ expect(buttonSaying(wrapper, "cancel")!.attributes("disabled")).toBeDefined();
+
+ for (const tile of wrapper.findAll(".picker-cell")) {
+ expect(tile.attributes("disabled")).toBeDefined();
+ }
+
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+
+ expect(wrapper.emitted("update:open")).toBeUndefined();
+
+ wrapper.unmount();
+ });
+
+ test("Escape and Cancel close it when nothing is on its way", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing());
+
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ await buttonSaying(wrapper, "cancel")!.trigger("click");
+
+ expect(wrapper.emitted("update:open")).toEqual([[false], [false]]);
+
+ wrapper.unmount();
+ });
+
+ test("a draft is thrown away on close and the worn row is selected again on reopen", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME)));
+
+ await tileNamed(wrapper, "vines").trigger("click");
+ await wrapper.setProps({ open: false });
+ await wrapper.setProps({ open: true });
+
+ expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true");
+ expect(applyButton(wrapper).attributes("disabled")).toBeDefined();
+
+ wrapper.unmount();
+ });
+
+ test("asks again what may be worn every time it opens", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing());
+
+ expect(h.getMyCosmetics).toHaveBeenCalledTimes(2);
+
+ await wrapper.setProps({ open: false });
+ await wrapper.setProps({ open: true });
+ await flushPromises();
+
+ expect(h.getMyCosmetics).toHaveBeenCalledTimes(3);
+
+ wrapper.unmount();
+ });
+
+ test("an untouched draft follows the look when it changes elsewhere; a touched one stays", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME)));
+
+ await wrapper.setProps({ profile: wearing(new WornItem(FREE_FRAME)) });
+
+ expect(tileNamed(wrapper, "vines").attributes("aria-checked")).toBe("true");
+ expect(applyButton(wrapper).attributes("disabled")).toBeDefined();
+
+ await tileNamed(wrapper, "thorns").trigger("click");
+ await wrapper.setProps({ profile: wearing() });
+
+ expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true");
+
+ wrapper.unmount();
+ });
+
+ test("nothing worn offers nothing to take off", async () => {
+ const wrapper = await mountPicker("avatar.decoration", wearing());
+
+ expect(buttonSaying(wrapper, "cosmetic_clear")).toBeUndefined();
+
+ wrapper.unmount();
+ });
+
+ test("the previews draw the draft over the rest of the look", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME), new WornItem(DECORATION)));
+
+ await tileNamed(wrapper, "vines").trigger("click");
+
+ const previews = wrapper.findAllComponents({ name: "CosmeticLookPreview" });
+
+ expect(previews).toHaveLength(2);
+
+ for (const preview of previews) {
+ const drawn = (preview.props("profile") as ArgonUserProfile).cosmetics ?? [];
+
+ expect(drawn.map(entry => (entry as WornItem).itemId)).toEqual([DECORATION, FREE_FRAME]);
+ }
+
+ wrapper.unmount();
+ });
+});
+
+describe("keyboard", () => {
+ test("the chosen tile is the only tab stop; with none chosen the first that can be chosen stands in", async () => {
+ const chosen = await mountPicker("profile.frame", wearing(new WornItem(WORN_FRAME)));
+
+ expect(chosen.findAll(".picker-cell").map(tile => tile.attributes("tabindex"))).toEqual(["-1", "0", "-1"]);
+
+ chosen.unmount();
+
+ const none = await mountPicker("profile.frame", wearing());
+
+ expect(none.findAll(".picker-cell").map(tile => tile.attributes("tabindex"))).toEqual(["0", "-1", "-1"]);
+
+ none.unmount();
+ });
+
+ test("arrows, Home and End choose along what can be chosen, wrapping and skipping a locked tile", async () => {
+ const wrapper = await mountPicker("profile.frame", wearing(new WornItem(FREE_FRAME)));
+
+ await tileNamed(wrapper, "vines").trigger("keydown", { key: "ArrowRight" });
+
+ expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true");
+ expect(document.activeElement).toBe(tileNamed(wrapper, "thorns").element);
+
+ await tileNamed(wrapper, "thorns").trigger("keydown", { key: "ArrowRight" });
+
+ expect(tileNamed(wrapper, "vines").attributes("aria-checked")).toBe("true");
+
+ await tileNamed(wrapper, "vines").trigger("keydown", { key: "End" });
+
+ expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true");
+ expect(tileNamed(wrapper, "gilded").attributes("aria-checked")).toBe("false");
+
+ await tileNamed(wrapper, "thorns").trigger("keydown", { key: "Home", altKey: true });
+
+ expect(tileNamed(wrapper, "thorns").attributes("aria-checked")).toBe("true");
+
+ wrapper.unmount();
+ });
+});
diff --git a/test/components/CosmeticsPanel.test.ts b/test/components/CosmeticsPanel.test.ts
new file mode 100644
index 00000000..a587232d
--- /dev/null
+++ b/test/components/CosmeticsPanel.test.ts
@@ -0,0 +1,312 @@
+/**
+ * The wardrobe list: each line says what is worn on its kind, and the picker it opens closes only once
+ * the server took the change. A refusal is said in its own words, a lost call is said as one, and
+ * either leaves the picker up with the draft intact; taking off something already off is not a failure.
+ */
+
+import {
+ beforeEach,
+ describe,
+ expect,
+ test,
+ vi,
+} from "vitest";
+import {
+ flushPromises,
+ mount,
+ type VueWrapper,
+} from "@vue/test-utils";
+
+const h = vi.hoisted(() => ({
+ equip: vi.fn(),
+ unequip: vi.fn(),
+ toast: vi.fn(),
+ worn: new Map(),
+ items: new Map(),
+}));
+
+vi.mock("@argon/core", () => ({
+ logger: { log() {}, debug() {}, info() {}, warn() {}, error() {} },
+}));
+vi.mock("@argon/ui/toast", () => ({ useToast: () => ({ toast: h.toast }) }));
+vi.mock("@/store/system/localeStore", async () => {
+ const { ref } = await import("vue");
+ const currentLocale = ref("en");
+
+ return {
+ useLocale: () => ({ t: (key: string) => key, currentLocale }),
+ };
+});
+vi.mock("@/store/auth/meStore", async () => {
+ const { ref } = await import("vue");
+ const me = {
+ me: { userId: "me", displayName: "Omni", username: "omni", avatarFileId: null },
+ meProfile: ref({ userId: "me", cosmetics: [] }),
+ isPremium: false,
+ };
+
+ return { useMe: () => me };
+});
+vi.mock("@/store/features/cosmeticsStore", async () => {
+ const { reactive, ref } = await import("vue");
+ const wearableKinds = ref([
+ { key: "profile.frame", labelKey: "kind_profile_frame", bare: false },
+ { key: "nickname.style", labelKey: "kind_nickname_style", bare: true },
+ ]);
+ const store = reactive({
+ wearableKinds,
+ ensureLoaded: async () => {},
+ optionsFor: () => [...h.items.values()],
+ wornOfKind: (_profile: unknown, kindKey: string) => h.worn.get(kindKey) ?? null,
+ itemById: (itemId: string | null | undefined) => (itemId ? h.items.get(itemId) ?? null : null),
+ equip: (...args: unknown[]) => h.equip(...args),
+ unequip: (...args: unknown[]) => h.unequip(...args),
+ });
+
+ return { useCosmeticsStore: () => store };
+});
+vi.mock("@/components/settings/cosmetics/CosmeticRowPreview.vue", async () => {
+ const { defineComponent, h: render } = await import("vue");
+
+ return { default: defineComponent({ setup: () => () => render("span") }) };
+});
+vi.mock("@/components/settings/cosmetics/CosmeticPickerDialog.vue", async () => {
+ const { defineComponent, h: render } = await import("vue");
+
+ return {
+ default: defineComponent({
+ name: "CosmeticPickerDialog",
+ props: {
+ open: { type: Boolean, required: true },
+ kind: { type: Object, required: true },
+ profile: { type: Object, default: null },
+ items: { type: Array, required: true },
+ busy: { type: Boolean, required: true },
+ canUpsell: { type: Boolean, default: false },
+ },
+ emits: ["update:open", "apply", "upsell"],
+ setup: props => () => (props.open ? render("div", { class: "picker-stub" }) : null),
+ }),
+ };
+});
+
+import {
+ CosmeticError,
+ WornItem,
+ type CatalogueCosmetic,
+} from "@argon/glue";
+
+import CosmeticsPanel from "@/components/settings/cosmetics/CosmeticsPanel.vue";
+import {
+ equipChange,
+ unequipChange,
+ type WardrobeChange,
+} from "@/components/settings/cosmetics/wardrobeChange";
+
+const FRAME = "aaaaaaaa-0000-0000-0000-000000000001";
+
+const KIND_INDEX: Record = { "profile.frame": 0 };
+
+function frameRow(): CatalogueCosmetic {
+ return {
+ cosmeticId: FRAME,
+ kindKey: "profile.frame",
+ slug: "vines",
+ nameKey: "cosmetic_vines",
+ text: [{ locale: "en", name: "Vines", description: null }],
+ } as unknown as CatalogueCosmetic;
+}
+
+async function mountPanel(props: { canUpsell?: boolean } = {}): Promise {
+ const wrapper = mount(CosmeticsPanel, { props });
+
+ await flushPromises();
+
+ return wrapper;
+}
+
+function rowOf(wrapper: VueWrapper, kindKey: string) {
+ return wrapper.findAll(".worn-row")[KIND_INDEX[kindKey]];
+}
+
+function wornLabelOf(wrapper: VueWrapper, kindKey: string): string {
+ return rowOf(wrapper, kindKey).get(".worn-value").text();
+}
+
+function picker(wrapper: VueWrapper) {
+ return wrapper.getComponent({ name: "CosmeticPickerDialog" });
+}
+
+function pickerIsOpen(wrapper: VueWrapper): boolean {
+ return wrapper.find(".picker-stub").exists();
+}
+
+async function openPicker(wrapper: VueWrapper, kindKey: string): Promise {
+ await rowOf(wrapper, kindKey).trigger("click");
+}
+
+async function send(wrapper: VueWrapper, change: WardrobeChange): Promise {
+ picker(wrapper).vm.$emit("apply", change);
+ await flushPromises();
+}
+
+function frameChange(itemId = FRAME): WardrobeChange {
+ return equipChange("profile.frame", new WornItem(itemId));
+}
+
+beforeEach(() => {
+ h.equip.mockReset();
+ h.unequip.mockReset();
+ h.toast.mockReset();
+ h.worn.clear();
+ h.items.clear();
+ h.items.set(FRAME, frameRow());
+});
+
+describe("worn label", () => {
+ test("names the item worn on the kind", async () => {
+ h.worn.set("profile.frame", new WornItem(FRAME));
+
+ const wrapper = await mountPanel();
+
+ expect(wornLabelOf(wrapper, "profile.frame")).toBe("Vines");
+ });
+
+ test("says none when nothing is worn", async () => {
+ const wrapper = await mountPanel();
+
+ expect(wornLabelOf(wrapper, "profile.frame")).toBe("none");
+ });
+
+ test("says none when the worn item is not in the catalogue", async () => {
+ h.worn.set("profile.frame", new WornItem("bbbbbbbb-0000-0000-0000-000000000009"));
+
+ const wrapper = await mountPanel();
+
+ expect(wornLabelOf(wrapper, "profile.frame")).toBe("none");
+ });
+
+ test("a bare kind is not offered", async () => {
+ const wrapper = await mountPanel();
+
+ expect(wrapper.findAll(".worn-row")).toHaveLength(1);
+ });
+});
+
+describe("the picker", () => {
+ test("opens on the kind of its row, with that kind's rows to choose between", async () => {
+ const wrapper = await mountPanel();
+
+ await openPicker(wrapper, "profile.frame");
+
+ expect(pickerIsOpen(wrapper)).toBe(true);
+ expect((picker(wrapper).props("kind") as { key: string }).key).toBe("profile.frame");
+ expect(picker(wrapper).props("items")).toEqual([frameRow()]);
+ });
+
+ test("closes when it asks to", async () => {
+ const wrapper = await mountPanel();
+
+ await openPicker(wrapper, "profile.frame");
+ picker(wrapper).vm.$emit("update:open", false);
+ await flushPromises();
+
+ expect(pickerIsOpen(wrapper)).toBe(false);
+ });
+
+ test("an upsell closes it and is passed up", async () => {
+ const wrapper = await mountPanel({ canUpsell: true });
+
+ await openPicker(wrapper, "profile.frame");
+
+ expect(picker(wrapper).props("canUpsell")).toBe(true);
+
+ picker(wrapper).vm.$emit("upsell");
+ await flushPromises();
+
+ expect(pickerIsOpen(wrapper)).toBe(false);
+ expect(wrapper.emitted("upsell")).toHaveLength(1);
+ });
+});
+
+describe("applying from the picker", () => {
+ test("an equip goes to the store with the worn entry, and closes once it takes", async () => {
+ h.equip.mockResolvedValue(null);
+
+ const wrapper = await mountPanel();
+ const change = frameChange();
+
+ await openPicker(wrapper, "profile.frame");
+ await send(wrapper, change);
+
+ expect(h.equip).toHaveBeenCalledWith(change.action === "equip" ? change.worn : null);
+ expect(h.toast).not.toHaveBeenCalled();
+ expect(pickerIsOpen(wrapper)).toBe(false);
+ });
+
+ test("an unequip goes to the store by kind, and taking off something already off is quietly fine", async () => {
+ h.unequip.mockResolvedValue(CosmeticError.NOT_FOUND);
+
+ const wrapper = await mountPanel();
+
+ await openPicker(wrapper, "profile.frame");
+ await send(wrapper, unequipChange("profile.frame"));
+
+ expect(h.unequip).toHaveBeenCalledWith("profile.frame");
+ expect(h.toast).not.toHaveBeenCalled();
+ expect(pickerIsOpen(wrapper)).toBe(false);
+ });
+
+ test("a refusal is reported in its own words and leaves the picker up", async () => {
+ h.equip.mockResolvedValue(CosmeticError.NOT_OWNED);
+
+ const wrapper = await mountPanel();
+
+ await openPicker(wrapper, "profile.frame");
+ await send(wrapper, frameChange());
+
+ expect(h.toast).toHaveBeenCalledWith(expect.objectContaining({
+ title: "error",
+ description: "cosmetic_error_not_owned",
+ variant: "destructive",
+ }));
+ expect(pickerIsOpen(wrapper)).toBe(true);
+ });
+
+ test("a call that never arrives is reported as one and leaves the picker up", async () => {
+ h.equip.mockRejectedValue(new Error("offline"));
+
+ const wrapper = await mountPanel();
+
+ await openPicker(wrapper, "profile.frame");
+ await send(wrapper, frameChange());
+
+ expect(h.toast).toHaveBeenCalledWith(expect.objectContaining({ description: "cosmetic_error_network" }));
+ expect(pickerIsOpen(wrapper)).toBe(true);
+ expect(picker(wrapper).props("busy")).toBe(false);
+ });
+
+ test("while one change is on its way the rows and the picker are busy, and a second is ignored", async () => {
+ let settle!: (value: null) => void;
+
+ h.equip.mockReturnValue(new Promise(resolve => {
+ settle = resolve;
+ }));
+
+ const wrapper = await mountPanel();
+
+ await openPicker(wrapper, "profile.frame");
+ await send(wrapper, frameChange("a"));
+
+ expect(picker(wrapper).props("busy")).toBe(true);
+ expect(rowOf(wrapper, "profile.frame").attributes("disabled")).toBeDefined();
+
+ await send(wrapper, frameChange("b"));
+
+ settle(null);
+ await flushPromises();
+
+ expect(h.equip).toHaveBeenCalledTimes(1);
+ expect(pickerIsOpen(wrapper)).toBe(false);
+ });
+});
diff --git a/test/cosmetics/cosmeticText.test.ts b/test/cosmetics/cosmeticText.test.ts
new file mode 100644
index 00000000..5782c87d
--- /dev/null
+++ b/test/cosmetics/cosmeticText.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+
+import { cosmeticDescription, cosmeticName } from "@/lib/cosmeticText";
+
+function item(text: Array<{ locale: string; name: string; description: string | null }> | null) {
+ return { nameKey: "cosmetic_background_sakura", text } as never;
+}
+
+describe("cosmeticText", () => {
+ it("prefers the reader's own language", () => {
+ const subject = item([
+ { locale: "en", name: "Sakura", description: null },
+ { locale: "am", name: "Սակուրա", description: null },
+ ]);
+
+ expect(cosmeticName(subject, "am")).toBe("Սակուրա");
+ });
+
+ it("falls back to english when the language is not written", () => {
+ const subject = item([{ locale: "en", name: "Sakura", description: null }]);
+
+ expect(cosmeticName(subject, "jp")).toBe("Sakura");
+ });
+
+ it("falls back to the key when nothing is written", () => {
+ expect(cosmeticName(item(null), "am")).toBe("cosmetic_background_sakura");
+ expect(cosmeticName(item([]), "am")).toBe("cosmetic_background_sakura");
+ });
+
+ it("does not take english text for a language that has its own name but no description", () => {
+ const subject = item([
+ { locale: "en", name: "Sakura", description: "A tree" },
+ { locale: "am", name: "Սակուրա", description: null },
+ ]);
+
+ expect(cosmeticDescription(subject, "am")).toBeNull();
+ });
+
+ it("has no description when nothing is written", () => {
+ expect(cosmeticDescription(item(null), "en")).toBeNull();
+ });
+});
diff --git a/test/cosmetics/leadingGraphemes.test.ts b/test/cosmetics/leadingGraphemes.test.ts
new file mode 100644
index 00000000..8d46273a
--- /dev/null
+++ b/test/cosmetics/leadingGraphemes.test.ts
@@ -0,0 +1,24 @@
+import {
+ describe,
+ expect,
+ test,
+} from "vitest";
+
+import { leadingGraphemes } from "@/cosmetics/preview/leadingGraphemes";
+
+describe("leadingGraphemes", () => {
+ test("keeps a short name whole and cuts a long one", () => {
+ expect(leadingGraphemes("Omni", 6)).toBe("Omni");
+ expect(leadingGraphemes("Omnifaced", 6)).toBe("Omnifa");
+ });
+
+ test("never splits a surrogate pair or a joined emoji", () => {
+ expect(leadingGraphemes("ab😀cd", 3)).toBe("ab😀");
+ expect(leadingGraphemes("👨👩👧x", 1)).toBe("👨👩👧");
+ });
+
+ test("an empty name or a zero count is empty", () => {
+ expect(leadingGraphemes("", 6)).toBe("");
+ expect(leadingGraphemes("Omni", 0)).toBe("");
+ });
+});
diff --git a/test/cosmetics/wardrobeChange.test.ts b/test/cosmetics/wardrobeChange.test.ts
new file mode 100644
index 00000000..9a45f278
--- /dev/null
+++ b/test/cosmetics/wardrobeChange.test.ts
@@ -0,0 +1,98 @@
+/**
+ * What one Apply in the wardrobe sends. A catalogue kind wears one row (`WornItem`), and choosing
+ * nothing takes the kind off instead of saving an empty look.
+ */
+
+import {
+ describe,
+ expect,
+ test,
+} from "vitest";
+import {
+ CosmeticError,
+ WornItem,
+ type CatalogueCosmetic,
+ type IWornCosmetic,
+} from "@argon/glue";
+
+import {
+ equipChange,
+ itemChange,
+ refusalOf,
+ unequipChange,
+ wornAfter,
+} from "@/components/settings/cosmetics/wardrobeChange";
+import {
+ availableUntilOf,
+ lockReasonOf,
+ lockedAccessibleName,
+} from "@/components/settings/cosmetics/cosmeticLock";
+
+function row(overrides: Partial = {}): CatalogueCosmetic {
+ return {
+ cosmeticId: "aaaaaaaa-0000-0000-0000-000000000001",
+ kindKey: "profile.frame",
+ slug: "frame",
+ nameKey: "cosmetic_frame",
+ descriptionKey: null,
+ rarity: null,
+ version: 1,
+ payload: null as never,
+ assets: [],
+ availableUntil: null,
+ free: false,
+ ultima: false,
+ text: [],
+ ...overrides,
+ };
+}
+
+describe("item kinds", () => {
+ test("a chosen row is equipped as a WornItem carrying its id", () => {
+ const change = itemChange("profile.frame", "frame-1");
+
+ expect(change.action).toBe("equip");
+ expect(change.kindKey).toBe("profile.frame");
+
+ const worn = wornAfter(change);
+
+ expect(worn).toBeInstanceOf(WornItem);
+ expect((worn as WornItem).itemId).toBe("frame-1");
+ });
+
+ test("no row is an unequip of the kind", () => {
+ expect(itemChange("avatar.decoration", null)).toEqual({ action: "unequip", kindKey: "avatar.decoration" });
+ });
+});
+
+describe("locks", () => {
+ test("the caption says why", () => {
+ expect(lockReasonOf(row({ ultima: true }), false)).toBe("ultima");
+ expect(lockReasonOf(row(), false)).toBe("granted");
+ expect(lockReasonOf(row(), true)).toBeNull();
+ });
+
+ test("availability is read off the row when it has an end", () => {
+ const end = new Date("2026-12-31T00:00:00Z");
+
+ expect(availableUntilOf(row())).toBeNull();
+ expect(availableUntilOf(row({ availableUntil: { toDate: () => end } as never }))).toEqual(end);
+ });
+
+ test("a locked row says so in its accessible name", () => {
+ expect(lockedAccessibleName("Vines", true, "Locked")).toBe("Vines");
+ expect(lockedAccessibleName("Vines", false, "Locked")).toBe("Vines, Locked");
+ });
+});
+
+describe("refusals", () => {
+ test("NOT_FOUND on an unequip is the asked-for state", () => {
+ expect(refusalOf(unequipChange("profile.frame"), CosmeticError.NOT_FOUND)).toBeNull();
+ });
+
+ test("NOT_FOUND on an equip is still a refusal, and so is anything else on an unequip", () => {
+ expect(refusalOf(equipChange("profile.frame", new WornItem("x")), CosmeticError.NOT_FOUND)).toBe(CosmeticError.NOT_FOUND);
+ expect(refusalOf(unequipChange("profile.frame"), CosmeticError.KIND_DISABLED)).toBe(CosmeticError.KIND_DISABLED);
+ expect(refusalOf(unequipChange("profile.frame"), null)).toBeNull();
+ });
+});
diff --git a/test/lib/cosmeticErrorKey.test.ts b/test/lib/cosmeticErrorKey.test.ts
new file mode 100644
index 00000000..707d4c90
--- /dev/null
+++ b/test/lib/cosmeticErrorKey.test.ts
@@ -0,0 +1,45 @@
+/**
+ * Every reason the server gives for refusing an equip has its own sentence; a reason this build does
+ * not know falls back to the generic one rather than rendering a key.
+ */
+
+import {
+ describe,
+ expect,
+ test,
+} from "vitest";
+import { CosmeticError } from "@argon/glue";
+
+import { cosmeticErrorKey } from "@/lib/refusals";
+
+import en from "../../packages/i18n/src/core/en.json";
+import ru from "../../packages/i18n/src/core/ru.json";
+
+describe("cosmeticErrorKey", () => {
+ test.each([
+ [CosmeticError.NOT_FOUND, "cosmetic_error_not_found"],
+ [CosmeticError.UNKNOWN_KIND, "cosmetic_error_unknown_kind"],
+ [CosmeticError.KIND_DISABLED, "cosmetic_error_kind_disabled"],
+ [CosmeticError.ITEM_UNAVAILABLE, "cosmetic_error_item_unavailable"],
+ [CosmeticError.NOT_OWNED, "cosmetic_error_not_owned"],
+ [CosmeticError.CHOICE_INVALID, "cosmetic_error_choice_invalid"],
+ [CosmeticError.VALUE_OUT_OF_RANGE, "cosmetic_error_value_out_of_range"],
+ ])("%s maps to %s", (error, key) => {
+ expect(cosmeticErrorKey(error)).toBe(key);
+ });
+
+ test("a reason from a newer server gets the generic sentence", () => {
+ expect(cosmeticErrorKey(99 as CosmeticError)).toBe("cosmetic_equip_failed");
+ });
+
+ test("every key it can return is written in English and Russian", () => {
+ const reasons = [0, 1, 2, 3, 4, 5, 6, 99] as CosmeticError[];
+
+ for (const reason of reasons) {
+ const key = cosmeticErrorKey(reason);
+
+ expect(en).toHaveProperty([key]);
+ expect(ru).toHaveProperty([key]);
+ }
+ });
+});
diff --git a/test/lib/modalLayers.test.ts b/test/lib/modalLayers.test.ts
new file mode 100644
index 00000000..d74e3699
--- /dev/null
+++ b/test/lib/modalLayers.test.ts
@@ -0,0 +1,249 @@
+/**
+ * The settings window closes itself on Escape from a `window` listener, and a dialog opened inside it
+ * may claim that key. Only dialogs that registered through useEscapeDismiss claim it, the topmost of
+ * them takes one press, and every other dialog leaves the key to the window as before.
+ */
+
+import {
+ afterEach,
+ describe,
+ expect,
+ test,
+ vi,
+} from "vitest";
+import {
+ defineComponent,
+ h,
+ nextTick,
+ ref,
+} from "vue";
+import {
+ mount,
+ type VueWrapper,
+} from "@vue/test-utils";
+
+import {
+ isEscapeLayerOpen,
+ useEscapeDismiss,
+} from "@/lib/modalLayers";
+
+const Layer = defineComponent({
+ props: {
+ open: { type: Boolean, required: true },
+ dismiss: { type: Function, required: true },
+ },
+ setup(props) {
+ useEscapeDismiss(() => props.open, () => props.dismiss());
+
+ return () => h("div");
+ },
+});
+
+const mounted: VueWrapper[] = [];
+
+function mountLayer(open: boolean, dismiss: () => void = vi.fn()): VueWrapper {
+ const wrapper = mount(Layer, { props: { open, dismiss } });
+
+ mounted.push(wrapper);
+
+ return wrapper;
+}
+
+function press(key = "Escape", target: EventTarget = window): KeyboardEvent {
+ const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
+
+ target.dispatchEvent(event);
+
+ return event;
+}
+
+afterEach(() => {
+ for (const wrapper of mounted.splice(0)) {
+ wrapper.unmount();
+ }
+
+ document.body.innerHTML = "";
+});
+
+describe("the settings window's guard", () => {
+ test("nothing open is nothing open", () => {
+ expect(isEscapeLayerOpen()).toBe(false);
+ });
+
+ test("a dialog that never registered leaves Escape to the window", () => {
+ document.body.innerHTML = `
`;
+
+ expect(isEscapeLayerOpen()).toBe(false);
+ });
+
+ test("an open registered layer claims it", () => {
+ mountLayer(true);
+
+ expect(isEscapeLayerOpen()).toBe(true);
+ });
+
+ test("a registered layer that is closed, or closing, does not", () => {
+ mountLayer(false);
+
+ expect(isEscapeLayerOpen()).toBe(false);
+ });
+
+ test("an unmounted layer stops counting", () => {
+ mountLayer(true).unmount();
+ mounted.length = 0;
+
+ expect(isEscapeLayerOpen()).toBe(false);
+ });
+
+ test("the window's own handler sees the press only when no registered layer is open", async () => {
+ const closeSettings = vi.fn();
+
+ function handleEscape(event: KeyboardEvent): void {
+ if (event.key === "Escape" && !isEscapeLayerOpen()) {
+ closeSettings();
+ }
+ }
+
+ window.addEventListener("keydown", handleEscape);
+
+ try {
+ const layer = mountLayer(true);
+
+ press("Escape", document.body);
+ expect(closeSettings).not.toHaveBeenCalled();
+
+ await layer.setProps({ open: false });
+ press("Escape", document.body);
+ expect(closeSettings).toHaveBeenCalledOnce();
+ } finally {
+ window.removeEventListener("keydown", handleEscape);
+ }
+ });
+});
+
+describe("escape dismiss", () => {
+ test("an open layer takes the key and stops it reaching the window's own handler", () => {
+ const dismiss = vi.fn();
+ const seenByWindow = vi.fn();
+
+ window.addEventListener("keydown", seenByWindow);
+
+ try {
+ mountLayer(true, dismiss);
+
+ const event = press();
+
+ expect(dismiss).toHaveBeenCalledOnce();
+ expect(event.defaultPrevented).toBe(true);
+ expect(seenByWindow).not.toHaveBeenCalled();
+ } finally {
+ window.removeEventListener("keydown", seenByWindow);
+ }
+ });
+
+ test("other keys pass", () => {
+ const dismiss = vi.fn();
+
+ mountLayer(true, dismiss);
+
+ expect(press("Enter").defaultPrevented).toBe(false);
+ expect(dismiss).not.toHaveBeenCalled();
+ });
+
+ test("a closed layer lets the key past", () => {
+ const dismiss = vi.fn();
+
+ mountLayer(false, dismiss);
+
+ expect(press().defaultPrevented).toBe(false);
+ expect(dismiss).not.toHaveBeenCalled();
+ });
+
+ test("an unmounted layer stops listening", () => {
+ const dismiss = vi.fn();
+
+ mountLayer(true, dismiss).unmount();
+ mounted.length = 0;
+ press();
+
+ expect(dismiss).not.toHaveBeenCalled();
+ });
+
+ test("a press something earlier already claimed is left alone", () => {
+ const dismiss = vi.fn();
+
+ function claim(event: KeyboardEvent): void {
+ event.preventDefault();
+ }
+
+ window.addEventListener("keydown", claim, true);
+
+ try {
+ mountLayer(true, dismiss);
+ press();
+
+ expect(dismiss).not.toHaveBeenCalled();
+ } finally {
+ window.removeEventListener("keydown", claim, true);
+ }
+ });
+});
+
+describe("stacked layers", () => {
+ test("one press dismisses only the layer on top", () => {
+ const dismissBottom = vi.fn();
+ const dismissTop = vi.fn();
+
+ mountLayer(true, dismissBottom);
+ mountLayer(true, dismissTop);
+
+ press();
+
+ expect(dismissTop).toHaveBeenCalledOnce();
+ expect(dismissBottom).not.toHaveBeenCalled();
+ });
+
+ test("the top is the layer opened last, not the one mounted last", async () => {
+ const dismissFirstMounted = vi.fn();
+ const dismissLastMounted = vi.fn();
+
+ const firstMounted = mountLayer(false, dismissFirstMounted);
+
+ mountLayer(true, dismissLastMounted);
+ await firstMounted.setProps({ open: true });
+
+ press();
+
+ expect(dismissFirstMounted).toHaveBeenCalledOnce();
+ expect(dismissLastMounted).not.toHaveBeenCalled();
+ });
+
+ test("once the top closes, the next press reaches the one beneath", async () => {
+ const dismissBottom = vi.fn();
+ const topOpen = ref(true);
+
+ mountLayer(true, dismissBottom);
+
+ const top = mount(defineComponent({
+ setup() {
+ useEscapeDismiss(() => topOpen.value, () => {
+ topOpen.value = false;
+ });
+
+ return () => h("div");
+ },
+ }));
+
+ mounted.push(top);
+
+ press();
+ await nextTick();
+
+ expect(topOpen.value).toBe(false);
+ expect(dismissBottom).not.toHaveBeenCalled();
+
+ press();
+
+ expect(dismissBottom).toHaveBeenCalledOnce();
+ });
+});