From 37f5ba0d73068e07f288d69104aaa899a4993e59 Mon Sep 17 00:00:00 2001 From: omnifaced Date: Sat, 17 Oct 2026 08:45:42 +0300 Subject: [PATCH] feat: nickname look editor in the wardrobe --- packages/i18n/src/core/am.json | 21 +- packages/i18n/src/core/de.json | 21 +- packages/i18n/src/core/en.json | 19 + packages/i18n/src/core/en_tengwar.json | 19 + packages/i18n/src/core/es.json | 21 +- packages/i18n/src/core/jp.json | 21 +- packages/i18n/src/core/kk.json | 21 +- packages/i18n/src/core/ko.json | 21 +- packages/i18n/src/core/pl.json | 21 +- packages/i18n/src/core/ru.json | 19 + packages/i18n/src/core/ru_pt.json | 21 +- packages/i18n/src/core/uz.json | 21 +- .../cosmetics/CosmeticPickerDialog.vue | 156 +- .../settings/cosmetics/CosmeticsPanel.vue | 9 +- .../settings/cosmetics/NicknameAxes.vue | 295 ++++ .../settings/cosmetics/nicknameDraft.ts | 135 ++ src/cosmetics/chips/FontChip.vue | 51 + src/cosmetics/chips/TextEffectChip.vue | 58 + src/cosmetics/chips/optionChips.ts | 15 + src/cosmetics/chips/types.ts | 29 + src/cosmetics/primitives/colourMix.ts | 63 + src/cosmetics/tuning/NicknameTuning.vue | 1252 +++++++++++++++++ src/cosmetics/tuning/nicknameTuningEdits.ts | 34 + test/components/CosmeticPickerDialog.test.ts | 186 ++- test/components/CosmeticsPanel.test.ts | 11 +- test/components/NicknameAxes.test.ts | 238 ++++ test/components/NicknameTuning.test.ts | 493 +++++++ test/cosmetics/colourMix.test.ts | 31 + test/cosmetics/nicknameTuningEdits.test.ts | 49 + test/cosmetics/wardrobeChange.test.ts | 127 +- 30 files changed, 3448 insertions(+), 30 deletions(-) create mode 100644 src/components/settings/cosmetics/NicknameAxes.vue create mode 100644 src/components/settings/cosmetics/nicknameDraft.ts create mode 100644 src/cosmetics/chips/FontChip.vue create mode 100644 src/cosmetics/chips/TextEffectChip.vue create mode 100644 src/cosmetics/chips/optionChips.ts create mode 100644 src/cosmetics/chips/types.ts create mode 100644 src/cosmetics/primitives/colourMix.ts create mode 100644 src/cosmetics/tuning/NicknameTuning.vue create mode 100644 src/cosmetics/tuning/nicknameTuningEdits.ts create mode 100644 test/components/NicknameAxes.test.ts create mode 100644 test/components/NicknameTuning.test.ts create mode 100644 test/cosmetics/colourMix.test.ts create mode 100644 test/cosmetics/nicknameTuningEdits.test.ts diff --git a/packages/i18n/src/core/am.json b/packages/i18n/src/core/am.json index fc14f2a9..20bb1880 100644 --- a/packages/i18n/src/core/am.json +++ b/packages/i18n/src/core/am.json @@ -2530,5 +2530,24 @@ "cosmetic_error_item_unavailable": "Այս իրն այլևս հասանելի չէ։", "cosmetic_error_not_owned": "Դուք դեռ չունեք այս իրը։", "cosmetic_error_choice_invalid": "Ընտրված տարբերակներից մեկն այստեղ չի համապատասխանում։", - "cosmetic_error_value_out_of_range": "Կարգավորումներից մեկը թույլատրելի սահմաններից դուրս է։" + "cosmetic_error_value_out_of_range": "Կարգավորումներից մեկը թույլատրելի սահմաններից դուրս է։", + "cosmetic_facet_font": "Տառատեսակ", + "cosmetic_facet_effect": "Էֆեկտ", + "cosmetic_configured": "Կարգավորված է", + "cosmetic_nothing_here_yet": "Այստեղ դեռ ոչինչ չկա։", + "cosmetic_tuning_colors": "Ձեր գույները", + "cosmetic_tuning_colors_hint": "Որ գույները։ Ինչպես են դրանք ներկվում՝ որոշում է վերևի էֆեկտը", + "cosmetic_tuning_add_stop": "Ավելացնել գույն", + "cosmetic_tuning_reorder": "Քաշեք՝ հերթականությունը փոխելու համար · Alt + ← →", + "cosmetic_tuning_angle": "Ուղղություն · {angle}°", + "cosmetic_tuning_shape": "Ձև", + "cosmetic_tuning_shape_linear": "Լայնքով", + "cosmetic_tuning_shape_radial": "Կենտրոնից", + "cosmetic_tuning_shape_conic": "Շուրջբոլորը", + "cosmetic_tuning_animate": "Շարժվող", + "cosmetic_tuning_weight": "Հաստություն", + "cosmetic_tuning_spacing": "Տառերի միջակայք", + "cosmetic_tuning_default": "Լռելյայն", + "cosmetic_tuning_reset_weight": "Վերականգնել հաստությունը", + "cosmetic_tuning_reset_spacing": "Վերականգնել տառերի միջակայքը" } diff --git a/packages/i18n/src/core/de.json b/packages/i18n/src/core/de.json index 3ec54810..1d4aa9ad 100644 --- a/packages/i18n/src/core/de.json +++ b/packages/i18n/src/core/de.json @@ -2530,5 +2530,24 @@ "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." + "cosmetic_error_value_out_of_range": "Eine der Einstellungen liegt außerhalb des erlaubten Bereichs.", + "cosmetic_facet_font": "Schriftart", + "cosmetic_facet_effect": "Effekt", + "cosmetic_configured": "Eingerichtet", + "cosmetic_nothing_here_yet": "Hier ist noch nichts.", + "cosmetic_tuning_colors": "Deine Farben", + "cosmetic_tuning_colors_hint": "Welche Farben. Wie sie gemalt werden, bestimmt der Effekt oben", + "cosmetic_tuning_add_stop": "Farbe hinzufügen", + "cosmetic_tuning_reorder": "Ziehen zum Umsortieren · Alt + ← →", + "cosmetic_tuning_angle": "Richtung · {angle}°", + "cosmetic_tuning_shape": "Form", + "cosmetic_tuning_shape_linear": "Quer", + "cosmetic_tuning_shape_radial": "Von innen", + "cosmetic_tuning_shape_conic": "Rundherum", + "cosmetic_tuning_animate": "Wandernd", + "cosmetic_tuning_weight": "Stärke", + "cosmetic_tuning_spacing": "Zeichenabstand", + "cosmetic_tuning_default": "Standard", + "cosmetic_tuning_reset_weight": "Stärke zurücksetzen", + "cosmetic_tuning_reset_spacing": "Zeichenabstand zurücksetzen" } diff --git a/packages/i18n/src/core/en.json b/packages/i18n/src/core/en.json index f8f83e27..6de1c8e6 100644 --- a/packages/i18n/src/core/en.json +++ b/packages/i18n/src/core/en.json @@ -2555,7 +2555,11 @@ "cosmetic_kind_nickname_style": "Name style", "cosmetic_kind_option_font": "Fonts", "cosmetic_kind_option_text_effect": "Name effects", + "cosmetic_facet_font": "Font", + "cosmetic_facet_effect": "Effect", + "cosmetic_configured": "Set up", "cosmetic_clear": "Take off", + "cosmetic_nothing_here_yet": "Nothing here yet.", "cosmetic_locked": "Locked", "cosmetic_locked_ultima": "With Ultima", "cosmetic_locked_grant": "Not yours yet", @@ -2564,6 +2568,21 @@ "cosmetic_surface_avatar": "Avatar", "cosmetic_surface_message": "In chat", "cosmetic_surface_member_row": "Member list", + "cosmetic_tuning_colors": "Your colours", + "cosmetic_tuning_colors_hint": "Which colours. How they are painted is the effect above", + "cosmetic_tuning_add_stop": "Add a colour", + "cosmetic_tuning_reorder": "Drag to reorder · Alt + ← →", + "cosmetic_tuning_angle": "Direction · {angle}°", + "cosmetic_tuning_shape": "Shape", + "cosmetic_tuning_shape_linear": "Across", + "cosmetic_tuning_shape_radial": "Out", + "cosmetic_tuning_shape_conic": "Around", + "cosmetic_tuning_animate": "Travelling", + "cosmetic_tuning_weight": "Weight", + "cosmetic_tuning_spacing": "Letter spacing", + "cosmetic_tuning_default": "Default", + "cosmetic_tuning_reset_weight": "Reset weight", + "cosmetic_tuning_reset_spacing": "Reset letter spacing", "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.", diff --git a/packages/i18n/src/core/en_tengwar.json b/packages/i18n/src/core/en_tengwar.json index da171d5b..5faba98c 100644 --- a/packages/i18n/src/core/en_tengwar.json +++ b/packages/i18n/src/core/en_tengwar.json @@ -2555,7 +2555,11 @@ "cosmetic_kind_nickname_style": " ", "cosmetic_kind_option_font": "", "cosmetic_kind_option_text_effect": " ", + "cosmetic_facet_font": "", + "cosmetic_facet_effect": "", + "cosmetic_configured": " ", "cosmetic_clear": " ", + "cosmetic_nothing_here_yet": "   :", "cosmetic_locked": "", "cosmetic_locked_ultima": " ", "cosmetic_locked_grant": "  ", @@ -2564,6 +2568,21 @@ "cosmetic_surface_avatar": "", "cosmetic_surface_message": " ", "cosmetic_surface_member_row": " ", + "cosmetic_tuning_colors": " ", + "cosmetic_tuning_colors_hint": "  :        ", + "cosmetic_tuning_add_stop": "  ", + "cosmetic_tuning_reorder": "       ", + "cosmetic_tuning_angle": " {angle}°", + "cosmetic_tuning_shape": "", + "cosmetic_tuning_shape_linear": "", + "cosmetic_tuning_shape_radial": "", + "cosmetic_tuning_shape_conic": "", + "cosmetic_tuning_animate": "", + "cosmetic_tuning_weight": "", + "cosmetic_tuning_spacing": " ", + "cosmetic_tuning_default": "", + "cosmetic_tuning_reset_weight": " ", + "cosmetic_tuning_reset_spacing": "  ", "cosmetic_equip_failed": "     ", "cosmetic_error_network": "     :   :", "cosmetic_error_not_found": "     :", diff --git a/packages/i18n/src/core/es.json b/packages/i18n/src/core/es.json index 24443758..f46fc2ef 100644 --- a/packages/i18n/src/core/es.json +++ b/packages/i18n/src/core/es.json @@ -2530,5 +2530,24 @@ "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." + "cosmetic_error_value_out_of_range": "Uno de los ajustes está fuera de rango.", + "cosmetic_facet_font": "Fuente", + "cosmetic_facet_effect": "Efecto", + "cosmetic_configured": "Configurado", + "cosmetic_nothing_here_yet": "Aún no hay nada aquí.", + "cosmetic_tuning_colors": "Tus colores", + "cosmetic_tuning_colors_hint": "Qué colores. Cómo se pintan lo decide el efecto de arriba", + "cosmetic_tuning_add_stop": "Añadir un color", + "cosmetic_tuning_reorder": "Arrastra para reordenar · Alt + ← →", + "cosmetic_tuning_angle": "Dirección · {angle}°", + "cosmetic_tuning_shape": "Forma", + "cosmetic_tuning_shape_linear": "A lo ancho", + "cosmetic_tuning_shape_radial": "Desde el centro", + "cosmetic_tuning_shape_conic": "Alrededor", + "cosmetic_tuning_animate": "En movimiento", + "cosmetic_tuning_weight": "Grosor", + "cosmetic_tuning_spacing": "Espaciado entre letras", + "cosmetic_tuning_default": "Por defecto", + "cosmetic_tuning_reset_weight": "Restablecer grosor", + "cosmetic_tuning_reset_spacing": "Restablecer espaciado entre letras" } diff --git a/packages/i18n/src/core/jp.json b/packages/i18n/src/core/jp.json index e4c20128..57360bf7 100644 --- a/packages/i18n/src/core/jp.json +++ b/packages/i18n/src/core/jp.json @@ -2530,5 +2530,24 @@ "cosmetic_error_item_unavailable": "このアイテムはもう利用できません。", "cosmetic_error_not_owned": "このアイテムはまだ持っていません。", "cosmetic_error_choice_invalid": "選んだオプションのひとつはここでは使えません。", - "cosmetic_error_value_out_of_range": "設定のひとつが範囲外です。" + "cosmetic_error_value_out_of_range": "設定のひとつが範囲外です。", + "cosmetic_facet_font": "フォント", + "cosmetic_facet_effect": "エフェクト", + "cosmetic_configured": "設定済み", + "cosmetic_nothing_here_yet": "まだ何もありません。", + "cosmetic_tuning_colors": "自分の色", + "cosmetic_tuning_colors_hint": "どの色を使うか。塗り方は上のエフェクトで決まります", + "cosmetic_tuning_add_stop": "色を追加", + "cosmetic_tuning_reorder": "ドラッグで並べ替え · Alt + ← →", + "cosmetic_tuning_angle": "方向 · {angle}°", + "cosmetic_tuning_shape": "形", + "cosmetic_tuning_shape_linear": "横断", + "cosmetic_tuning_shape_radial": "中心から", + "cosmetic_tuning_shape_conic": "ぐるり", + "cosmetic_tuning_animate": "流れる", + "cosmetic_tuning_weight": "太さ", + "cosmetic_tuning_spacing": "字間", + "cosmetic_tuning_default": "デフォルト", + "cosmetic_tuning_reset_weight": "太さをリセット", + "cosmetic_tuning_reset_spacing": "字間をリセット" } diff --git a/packages/i18n/src/core/kk.json b/packages/i18n/src/core/kk.json index 06efeae9..148a6c88 100644 --- a/packages/i18n/src/core/kk.json +++ b/packages/i18n/src/core/kk.json @@ -2530,5 +2530,24 @@ "cosmetic_error_item_unavailable": "Бұл зат енді қолжетімсіз.", "cosmetic_error_not_owned": "Сізде бұл зат әлі жоқ.", "cosmetic_error_choice_invalid": "Таңдалған нұсқалардың бірі мұнда жарамайды.", - "cosmetic_error_value_out_of_range": "Баптаулардың бірі рұқсат етілген ауқымнан тыс." + "cosmetic_error_value_out_of_range": "Баптаулардың бірі рұқсат етілген ауқымнан тыс.", + "cosmetic_facet_font": "Қаріп", + "cosmetic_facet_effect": "Эффект", + "cosmetic_configured": "Бапталған", + "cosmetic_nothing_here_yet": "Мұнда әзірге ештеңе жоқ.", + "cosmetic_tuning_colors": "Өз түстеріңіз", + "cosmetic_tuning_colors_hint": "Қандай түстер. Олардың қалай боялатынын жоғарыдағы эффект анықтайды", + "cosmetic_tuning_add_stop": "Түс қосу", + "cosmetic_tuning_reorder": "Ретін өзгерту үшін сүйреңіз · Alt + ← →", + "cosmetic_tuning_angle": "Бағыт · {angle}°", + "cosmetic_tuning_shape": "Пішін", + "cosmetic_tuning_shape_linear": "Көлденең", + "cosmetic_tuning_shape_radial": "Ортадан", + "cosmetic_tuning_shape_conic": "Айнала", + "cosmetic_tuning_animate": "Жүгіретін", + "cosmetic_tuning_weight": "Қалыңдық", + "cosmetic_tuning_spacing": "Әріп аралығы", + "cosmetic_tuning_default": "Әдепкі", + "cosmetic_tuning_reset_weight": "Қалыңдықты қалпына келтіру", + "cosmetic_tuning_reset_spacing": "Әріп аралығын қалпына келтіру" } diff --git a/packages/i18n/src/core/ko.json b/packages/i18n/src/core/ko.json index 678a86d0..4b8016af 100644 --- a/packages/i18n/src/core/ko.json +++ b/packages/i18n/src/core/ko.json @@ -2530,5 +2530,24 @@ "cosmetic_error_item_unavailable": "더 이상 사용할 수 없는 아이템입니다.", "cosmetic_error_not_owned": "아직 이 아이템을 가지고 있지 않습니다.", "cosmetic_error_choice_invalid": "선택한 옵션 중 하나를 여기에서 사용할 수 없습니다.", - "cosmetic_error_value_out_of_range": "설정 값 중 하나가 허용 범위를 벗어났습니다." + "cosmetic_error_value_out_of_range": "설정 값 중 하나가 허용 범위를 벗어났습니다.", + "cosmetic_facet_font": "글꼴", + "cosmetic_facet_effect": "효과", + "cosmetic_configured": "설정됨", + "cosmetic_nothing_here_yet": "아직 아무것도 없습니다.", + "cosmetic_tuning_colors": "나만의 색상", + "cosmetic_tuning_colors_hint": "어떤 색을 쓸지 정합니다. 칠하는 방식은 위의 효과가 정합니다", + "cosmetic_tuning_add_stop": "색상 추가", + "cosmetic_tuning_reorder": "끌어서 순서 변경 · Alt + ← →", + "cosmetic_tuning_angle": "방향 · {angle}°", + "cosmetic_tuning_shape": "모양", + "cosmetic_tuning_shape_linear": "가로질러", + "cosmetic_tuning_shape_radial": "중심에서", + "cosmetic_tuning_shape_conic": "둘레로", + "cosmetic_tuning_animate": "흐르는 효과", + "cosmetic_tuning_weight": "굵기", + "cosmetic_tuning_spacing": "자간", + "cosmetic_tuning_default": "기본값", + "cosmetic_tuning_reset_weight": "굵기 초기화", + "cosmetic_tuning_reset_spacing": "자간 초기화" } diff --git a/packages/i18n/src/core/pl.json b/packages/i18n/src/core/pl.json index 606555f7..070a5732 100644 --- a/packages/i18n/src/core/pl.json +++ b/packages/i18n/src/core/pl.json @@ -2530,5 +2530,24 @@ "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." + "cosmetic_error_value_out_of_range": "Jedno z ustawień jest poza zakresem.", + "cosmetic_facet_font": "Czcionka", + "cosmetic_facet_effect": "Efekt", + "cosmetic_configured": "Ustawione", + "cosmetic_nothing_here_yet": "Na razie nic tu nie ma.", + "cosmetic_tuning_colors": "Twoje kolory", + "cosmetic_tuning_colors_hint": "Jakie kolory. Jak są malowane, decyduje efekt powyżej", + "cosmetic_tuning_add_stop": "Dodaj kolor", + "cosmetic_tuning_reorder": "Przeciągnij, aby zmienić kolejność · Alt + ← →", + "cosmetic_tuning_angle": "Kierunek · {angle}°", + "cosmetic_tuning_shape": "Kształt", + "cosmetic_tuning_shape_linear": "W poprzek", + "cosmetic_tuning_shape_radial": "Od środka", + "cosmetic_tuning_shape_conic": "Dookoła", + "cosmetic_tuning_animate": "W ruchu", + "cosmetic_tuning_weight": "Grubość", + "cosmetic_tuning_spacing": "Odstęp między literami", + "cosmetic_tuning_default": "Domyślnie", + "cosmetic_tuning_reset_weight": "Przywróć grubość", + "cosmetic_tuning_reset_spacing": "Przywróć odstęp między literami" } diff --git a/packages/i18n/src/core/ru.json b/packages/i18n/src/core/ru.json index 433af7e9..33b82e99 100644 --- a/packages/i18n/src/core/ru.json +++ b/packages/i18n/src/core/ru.json @@ -2555,7 +2555,11 @@ "cosmetic_kind_nickname_style": "Стиль имени", "cosmetic_kind_option_font": "Шрифты", "cosmetic_kind_option_text_effect": "Эффекты имени", + "cosmetic_facet_font": "Шрифт", + "cosmetic_facet_effect": "Эффект", + "cosmetic_configured": "Настроен", "cosmetic_clear": "Убрать", + "cosmetic_nothing_here_yet": "Пока пусто.", "cosmetic_locked": "Недоступно", "cosmetic_locked_ultima": "С Ultima", "cosmetic_locked_grant": "Пока не ваше", @@ -2564,6 +2568,21 @@ "cosmetic_surface_avatar": "Аватар", "cosmetic_surface_message": "В чате", "cosmetic_surface_member_row": "Список участников", + "cosmetic_tuning_colors": "Свои цвета", + "cosmetic_tuning_colors_hint": "Здесь — какими цветами. Чем их красить — «Эффект» выше", + "cosmetic_tuning_add_stop": "Добавить цвет", + "cosmetic_tuning_reorder": "Перетащите, чтобы поменять порядок · Alt + ← →", + "cosmetic_tuning_angle": "Направление · {angle}°", + "cosmetic_tuning_shape": "Форма", + "cosmetic_tuning_shape_linear": "Поперёк", + "cosmetic_tuning_shape_radial": "От центра", + "cosmetic_tuning_shape_conic": "По кругу", + "cosmetic_tuning_animate": "Бегущий", + "cosmetic_tuning_weight": "Толщина", + "cosmetic_tuning_spacing": "Межбуквенный интервал", + "cosmetic_tuning_default": "По умолчанию", + "cosmetic_tuning_reset_weight": "Сбросить толщину", + "cosmetic_tuning_reset_spacing": "Сбросить межбуквенный интервал", "cosmetic_equip_failed": "Не удалось надеть", "cosmetic_error_network": "Не удалось связаться с сервером. Попробуйте ещё раз.", "cosmetic_error_not_found": "Этого предмета больше нет.", diff --git a/packages/i18n/src/core/ru_pt.json b/packages/i18n/src/core/ru_pt.json index d7325a5c..962ce71a 100644 --- a/packages/i18n/src/core/ru_pt.json +++ b/packages/i18n/src/core/ru_pt.json @@ -2555,5 +2555,24 @@ "cosmetic_error_item_unavailable": "Эта вещица больше недоступна.", "cosmetic_error_not_owned": "Эта добыча пока не твоя.", "cosmetic_error_choice_invalid": "Один из выбранных вариантов тут не годится.", - "cosmetic_error_value_out_of_range": "Одна из настроек за бортом допустимого." + "cosmetic_error_value_out_of_range": "Одна из настроек за бортом допустимого.", + "cosmetic_facet_font": "Шрифт", + "cosmetic_facet_effect": "Эффект", + "cosmetic_configured": "Снаряжён", + "cosmetic_nothing_here_yet": "Тут пока пусто, как в трюме после шторма.", + "cosmetic_tuning_colors": "Свои цвета", + "cosmetic_tuning_colors_hint": "Здесь — какими цветами. Чем их красить — «Эффект» выше", + "cosmetic_tuning_add_stop": "Добавить цвет", + "cosmetic_tuning_reorder": "Тащи, чтобы поменять порядок · Alt + ← →", + "cosmetic_tuning_angle": "Курс · {angle}°", + "cosmetic_tuning_shape": "Форма", + "cosmetic_tuning_shape_linear": "Поперёк", + "cosmetic_tuning_shape_radial": "От центра", + "cosmetic_tuning_shape_conic": "По кругу", + "cosmetic_tuning_animate": "Бегущий", + "cosmetic_tuning_weight": "Толщина", + "cosmetic_tuning_spacing": "Межбуквенный интервал", + "cosmetic_tuning_default": "По умолчанию", + "cosmetic_tuning_reset_weight": "Сбросить толщину", + "cosmetic_tuning_reset_spacing": "Сбросить межбуквенный интервал" } diff --git a/packages/i18n/src/core/uz.json b/packages/i18n/src/core/uz.json index fb1e14b8..d49c5fd6 100644 --- a/packages/i18n/src/core/uz.json +++ b/packages/i18n/src/core/uz.json @@ -2530,5 +2530,24 @@ "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." + "cosmetic_error_value_out_of_range": "Sozlamalardan biri ruxsat etilgan oraliqdan tashqarida.", + "cosmetic_facet_font": "Shrift", + "cosmetic_facet_effect": "Effekt", + "cosmetic_configured": "Sozlangan", + "cosmetic_nothing_here_yet": "Bu yerda hali hech narsa yoʻq.", + "cosmetic_tuning_colors": "Oʻz ranglaringiz", + "cosmetic_tuning_colors_hint": "Qaysi ranglar. Ular qanday boʻyalishini yuqoridagi effekt belgilaydi", + "cosmetic_tuning_add_stop": "Rang qoʻshish", + "cosmetic_tuning_reorder": "Tartibni oʻzgartirish uchun torting · Alt + ← →", + "cosmetic_tuning_angle": "Yoʻnalish · {angle}°", + "cosmetic_tuning_shape": "Shakl", + "cosmetic_tuning_shape_linear": "Koʻndalang", + "cosmetic_tuning_shape_radial": "Markazdan", + "cosmetic_tuning_shape_conic": "Atrofida", + "cosmetic_tuning_animate": "Yuguruvchi", + "cosmetic_tuning_weight": "Qalinlik", + "cosmetic_tuning_spacing": "Harflar oraligʻi", + "cosmetic_tuning_default": "Standart", + "cosmetic_tuning_reset_weight": "Qalinlikni tiklash", + "cosmetic_tuning_reset_spacing": "Harflar oraligʻini tiklash" } diff --git a/src/components/settings/cosmetics/CosmeticPickerDialog.vue b/src/components/settings/cosmetics/CosmeticPickerDialog.vue index 4aa89e4f..dbda42ac 100644 --- a/src/components/settings/cosmetics/CosmeticPickerDialog.vue +++ b/src/components/settings/cosmetics/CosmeticPickerDialog.vue @@ -28,12 +28,21 @@ import { lockReasonOf, lockedAccessibleName, } from "@/components/settings/cosmetics/cosmeticLock"; +import { + emptyNicknameDraft, + isNicknameDraftAimable, + nicknameChange, + nicknameDraftOf, + sameNicknameDraft, + type NicknameDraft, +} from "@/components/settings/cosmetics/nicknameDraft"; import { focusRadioAt, radioStepTarget, } from "@/components/settings/cosmetics/rovingRadio"; import { itemChange, + unequipChange, wornAfter, type WardrobeChange, } from "@/components/settings/cosmetics/wardrobeChange"; @@ -42,8 +51,12 @@ import { useLocale, } from "@/store/system/localeStore"; import type { CosmeticKindModule } from "@/cosmetics/types"; +import type { NicknameAxes } from "@/cosmetics/wire"; +import type { NicknameStyleTuning } from "@/cosmetics/kinds/nickname-style"; import CosmeticCell from "@/components/settings/cosmetics/CosmeticCell.vue"; import CosmeticLookPreview, { type LookSurface } from "@/cosmetics/CosmeticLookPreview.vue"; +import NicknameAxesPicker from "@/components/settings/cosmetics/NicknameAxes.vue"; +import NicknameTuning from "@/cosmetics/tuning/NicknameTuning.vue"; import { asWornItem } from "@/cosmetics/wornEntry"; import { cosmeticIdKey } from "@/cosmetics/resolver"; import { cosmeticName } from "@/lib/cosmeticText"; @@ -88,6 +101,15 @@ const nameOf = (item: CatalogueCosmetic) => cosmeticName(item, currentLocale.val const wearable = (item: CatalogueCosmetic) => cosmetics.mayWear(item); +/** + * A kind with nothing to choose between. + * + * Everything it looks like comes from its axes and its wearer's own settings, so there is no grid: + * the dialog opens on the constructor. Two rows used to sit here and they were the same offer twice + * — whichever you picked, the axes and the tuning said it after. + */ +const bare = computed(() => props.kind.bare === true); + /** * What shape the tiles in the list are, decided by what is being chosen rather than by which kind. * @@ -118,10 +140,38 @@ const shape = computed(() => { /** What is worn on this kind right now. */ const current = computed(() => cosmetics.wornOfKind(props.profile, props.kind.key)); +/** Whether a bare kind is on at all, which is the one thing its row still records. */ +const bareWorn = computed(() => current.value !== null); + /** The catalogue row worn on this kind, if it is one. */ const wornItemId = computed(() => asWornItem(current.value)?.itemId ?? null); +/** + * Whether an axis row the worn look names can still be offered. + * + * A row the catalogue dropped, moved to another kind or had switched off is left out of the draft, + * so the next Apply does not send it back. Nothing is judged before the catalogue has arrived. + */ +function axisOffered(axis: keyof NicknameAxes, optionId: string): boolean { + if (cosmetics.catalogue === null) { + return true; + } + + const facet = props.kind.facets?.find(candidate => candidate.id === axis); + const row = cosmetics.itemById(optionId); + + if (facet === undefined || row === null) { + return false; + } + + return row.kindKey === facet.optionKindKey && cosmetics.isKindEnabled(row.kindKey); +} + +/** What the wearer has already decided about a bare kind: its axes and their own settings. */ +const wornNickname = computed(() => nicknameDraftOf(current.value, axisOffered)); + const picked = ref(null); +const nickname = ref(emptyNicknameDraft()); /** Opening starts from what is worn, never from a draft somebody walked away from. */ watch(() => [props.open, props.kind.key] as const, ([open]) => { @@ -130,6 +180,7 @@ watch(() => [props.open, props.kind.key] as const, ([open]) => { } picked.value = wornItemId.value; + nickname.value = wornNickname.value; // A grant can start or lapse while the dialog is shut; what may be worn is asked again each time. void cosmetics.loadOwned({ force: true }); @@ -149,10 +200,23 @@ watch(wornItemId, (worn, wornBefore) => { } }); +watch(wornNickname, (worn, wornBefore) => { + if (props.open && sameNicknameDraft(nickname.value, wornBefore)) { + nickname.value = worn; + } +}); + +/** Whether the axes and the tuning have anything to act on: something chosen, or a bare kind. */ +const configuring = computed(() => bare.value || picked.value !== null); + /** What Apply would ask the server for. */ -const change = computed(() => itemChange(props.kind.key, picked.value)); +const change = computed(() => (bare.value + ? nicknameChange(nickname.value, props.kind.key) + : itemChange(props.kind.key, picked.value))); -const dirty = computed(() => !sameId(picked.value, wornItemId.value)); +const dirty = computed(() => (bare.value + ? !sameNicknameDraft(nickname.value, wornNickname.value) + : !sameId(picked.value, wornItemId.value))); function requestOpen(open: boolean): void { if (!open && props.busy) { @@ -178,6 +242,8 @@ function toggle(item: CatalogueCosmetic | null): void { picked.value = item === null ? null : item.cosmeticId; } +const axes = computed(() => (props.kind.facets ?? []).filter(facet => cosmetics.isKindEnabled(facet.optionKindKey))); + /** The rest of the look: everything worn that this dialog is not about. */ const rest = computed(() => (props.profile?.cosmetics ?? []).filter(entry => entry !== current.value)); @@ -271,9 +337,21 @@ const sample = computed(() => me.me?.displayName || "Argon"); /** The account's own picture: a frame is chosen against the face it will sit on. */ const face = computed(() => me.me?.avatarFileId ?? undefined); -/** Every catalogue row the draft would put on: the chosen tile. */ +function setAxes(chosen: NicknameAxes): void { + nickname.value = { ...nickname.value, axes: chosen }; +} + +function setTuning(tuning: NicknameStyleTuning): void { + nickname.value = { ...nickname.value, tuning }; +} + +const aimable = computed(() => isNicknameDraftAimable(nickname.value)); + +/** Every catalogue row the draft would put on: the chosen tile, or the chosen axis rows. */ const draftRows = computed(() => { - const ids = [picked.value]; + const ids = bare.value + ? [nickname.value.axes.font, nickname.value.axes.effect] + : [picked.value]; const rows: CatalogueCosmetic[] = []; @@ -302,6 +380,10 @@ const subject = computed(() => { return lockedPick.value; } + if (bare.value) { + return null; + } + return hovered.value ?? itemById(picked.value); }); @@ -358,15 +440,21 @@ function apply(): void { * picture of nothing — and a kind with no grid at all could never have one, so half the dialogs * said "take it off" one way and half the other. * - * It clears the draft and leaves the rest to Apply, which is how every other click in this dialog - * behaves. + * A kind with tiles clears its draft and leaves the rest to Apply, which is how every other click + * in this dialog behaves. A bare kind has no draft to clear — being worn at all is the only thing + * its row records — so for that one, taking it off is the change, and it is written. */ function clear(): void { - toggle(null); + if (!bare.value) { + toggle(null); + return; + } + + emit("apply", unequipChange(props.kind.key)); } -/** Whether there is anything to take off: something in the draft. */ -const wearing = computed(() => picked.value !== null); +/** Whether there is anything to take off: something in the draft, or a bare kind that is on. */ +const wearing = computed(() => (bare.value ? bareWorn.value : picked.value !== null)); /** The tiles that can be chosen, in the order the grid draws them: what the arrow keys walk. */ const choosable = computed(() => props.items.filter(item => wearable(item))); @@ -433,6 +521,7 @@ function upsell(): void {
-
+
{{ t(lock.key) }} @@ -501,6 +590,35 @@ function upsell(): void { {{ t("cosmetic_available_until", { date: until }) }}
+ + +
+ +
+ + +
kind.key === kindKey)?.bare) { + return t("cosmetic_configured"); + } + const item = cosmetics.itemById(asWornItem(worn)?.itemId); return item ? cosmeticName(item, currentLocale.value) : t("none"); @@ -107,8 +113,7 @@ onMounted(async () => { * kinds are absent for a different reason — a font is not a thing you wear, it is a thing you * choose on the axis of something you do. */ -// A bare kind is set up in its own editor rather than picked from a grid; none is offered yet. -const kinds = computed(() => cosmetics.wearableKinds.filter(kind => kind.bare !== true)); +const kinds = computed(() => cosmetics.wearableKinds); async function run(action: () => Promise): Promise { if (busy.value) { diff --git a/src/components/settings/cosmetics/NicknameAxes.vue b/src/components/settings/cosmetics/NicknameAxes.vue new file mode 100644 index 00000000..6e621e80 --- /dev/null +++ b/src/components/settings/cosmetics/NicknameAxes.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/src/components/settings/cosmetics/nicknameDraft.ts b/src/components/settings/cosmetics/nicknameDraft.ts new file mode 100644 index 00000000..74c4022d --- /dev/null +++ b/src/components/settings/cosmetics/nicknameDraft.ts @@ -0,0 +1,135 @@ +import type { IWornCosmetic } from "@argon/glue"; + +import { + emptyNicknameStyleTuning, + isNicknameStyleTuningEmpty, + type NicknameStyleTuning, +} from "@/cosmetics/kinds/nickname-style"; +import { + nicknameAxesOf, + toWornNickname, + tuningOf, + type NicknameAxes, +} from "@/cosmetics/wire"; +import { asWornNickname } from "@/cosmetics/wornEntry"; + +import { + equipChange, + unequipChange, + type WardrobeChange, +} from "./wardrobeChange"; + +/** A nickname look being edited: the two axis rows and the wearer's own tuning. */ +export interface NicknameDraft { + readonly axes: NicknameAxes; + readonly tuning: NicknameStyleTuning; +} + +/** Whether an axis row can still be offered: present in the catalogue, of the facet's kind, and switched on. */ +export type AxisOffered = (axis: keyof NicknameAxes, optionId: string) => boolean; + +const OFFERS_EVERYTHING: AxisOffered = () => true; + +export function emptyNicknameDraft(): NicknameDraft { + return { + axes: { font: null, effect: null }, + tuning: emptyNicknameStyleTuning(), + }; +} + +function offeredOrNull(axis: keyof NicknameAxes, optionId: string | null, isOffered: AxisOffered): string | null { + if (optionId === null || !isOffered(axis, optionId)) { + return null; + } + + return optionId; +} + +/** + * The draft a worn entry opens as; anything that is not a readable nickname opens empty. An axis row + * the picker cannot offer is dropped, so the next Apply does not send it back. + */ +export function nicknameDraftOf(worn: IWornCosmetic | null, isOffered: AxisOffered = OFFERS_EVERYTHING): NicknameDraft { + const nickname = asWornNickname(worn); + + if (nickname === null) { + return emptyNicknameDraft(); + } + + const axes = nicknameAxesOf(nickname); + + return { + axes: { + font: offeredOrNull("font", axes.font, isOffered), + effect: offeredOrNull("effect", axes.effect, isOffered), + }, + tuning: tuningOf(nickname) ?? emptyNicknameStyleTuning(), + }; +} + +/** Angle, shape and motion only show on a gradient or under an effect; the picker hides them otherwise. */ +export function isNicknameDraftAimable(draft: NicknameDraft): boolean { + return (draft.tuning.colors?.length ?? 0) > 1 || draft.axes.effect !== null; +} + +/** The tuning as the wearer can see it: an aim the picker hides is not saved as an invisible look. */ +function visibleTuning(draft: NicknameDraft): NicknameStyleTuning { + if (isNicknameDraftAimable(draft)) { + return draft.tuning; + } + + return { + ...draft.tuning, + angle: null, + shape: null, + animate: null, + }; +} + +export function isNicknameDraftEmpty(draft: NicknameDraft): boolean { + return draft.axes.font === null + && draft.axes.effect === null + && isNicknameStyleTuningEmpty(visibleTuning(draft)); +} + +/** Option ids compare case-insensitively: guids reach the client from services that disagree on case. */ +function sameOption(left: string | null, right: string | null): boolean { + return (left?.toLowerCase() ?? null) === (right?.toLowerCase() ?? null); +} + +function sameColours(left: readonly number[] | null, right: readonly number[] | null): boolean { + const leftColours = left ?? []; + const rightColours = right ?? []; + + if (leftColours.length !== rightColours.length) { + return false; + } + + for (let index = 0; index < leftColours.length; index++) { + if ((leftColours[index] | 0) !== (rightColours[index] | 0)) { + return false; + } + } + + return true; +} + +export function sameNicknameDraft(left: NicknameDraft, right: NicknameDraft): boolean { + return sameOption(left.axes.font, right.axes.font) + && sameOption(left.axes.effect, right.axes.effect) + && sameColours(left.tuning.colors, right.tuning.colors) + && left.tuning.angle === right.tuning.angle + && left.tuning.shape === right.tuning.shape + && left.tuning.animate === right.tuning.animate + && left.tuning.weight === right.tuning.weight + && left.tuning.letterSpacingCentiEm === right.tuning.letterSpacingCentiEm; +} + +/** Nothing chosen takes the look off rather than saving an empty one. */ +export function nicknameChange(draft: NicknameDraft, kindKey: string): WardrobeChange { + if (isNicknameDraftEmpty(draft)) { + return unequipChange(kindKey); + } + + return equipChange(kindKey, toWornNickname(draft.axes, visibleTuning(draft))); +} diff --git a/src/cosmetics/chips/FontChip.vue b/src/cosmetics/chips/FontChip.vue new file mode 100644 index 00000000..d9c240eb --- /dev/null +++ b/src/cosmetics/chips/FontChip.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/cosmetics/chips/TextEffectChip.vue b/src/cosmetics/chips/TextEffectChip.vue new file mode 100644 index 00000000..2a59b49c --- /dev/null +++ b/src/cosmetics/chips/TextEffectChip.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/src/cosmetics/chips/optionChips.ts b/src/cosmetics/chips/optionChips.ts new file mode 100644 index 00000000..70d31e3f --- /dev/null +++ b/src/cosmetics/chips/optionChips.ts @@ -0,0 +1,15 @@ +import type { Component } from "vue"; + +import FontChip from "@/cosmetics/chips/FontChip.vue"; +import TextEffectChip from "@/cosmetics/chips/TextEffectChip.vue"; +import { resolveKind } from "@/cosmetics/registry"; + +const BUILT_IN_CHIPS: Readonly> = { + "option.font": FontChip, + "option.text-effect": TextEffectChip, +}; + +/** How a row of an option kind draws itself in a picker: the kind's own chip, else the built-in one. */ +export function chipForOptionKind(optionKindKey: string): Component | null { + return resolveKind(optionKindKey)?.chip ?? BUILT_IN_CHIPS[optionKindKey] ?? null; +} diff --git a/src/cosmetics/chips/types.ts b/src/cosmetics/chips/types.ts new file mode 100644 index 00000000..c4338f84 --- /dev/null +++ b/src/cosmetics/chips/types.ts @@ -0,0 +1,29 @@ +import type { ResolvedOption } from "@/cosmetics/types"; + +/** + * What a picker gives an option to draw itself with. + * + * A compositional kind supplies its own chip, because only it knows whether one of its rows is a + * face or a treatment — and a picker that switched on kind keys would be the one file every new + * option kind had to be added to. + */ +export interface CosmeticOptionChipProps { + /** The row as resolved: its slug, its payload, and the file a face is loaded from. */ + readonly option: ResolvedOption; + + /** Text to set the sample in, so a face is judged on real letters. */ + readonly sample: string; + + /** A readable stand-in colour, for a treatment that composes one before any is chosen. */ + readonly tint?: string | null; + + /** + * Every colour the item is actually carrying, where there is more than one. + * + * A chip has to be drawn in what the person will get. Showing each treatment over a single + * swatch while their name is painted in three colours of their own is showing them something that + * is not going to happen — and with the treatments that only compose several colours, it is a row + * of samples that all look the same. + */ + readonly stops?: readonly string[]; +} diff --git a/src/cosmetics/primitives/colourMix.ts b/src/cosmetics/primitives/colourMix.ts new file mode 100644 index 00000000..06c9afc8 --- /dev/null +++ b/src/cosmetics/primitives/colourMix.ts @@ -0,0 +1,63 @@ +interface ReadColour { + readonly channels: readonly number[]; + + /** As written, so it survives the mix unchanged; null when the colour carried none. */ + readonly alpha: string | null; +} + +const HEX_PATTERN = /^#[0-9a-f]{6}$/i; + +const RGB_PATTERN = /^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i; + +function readColour(colour: string): ReadColour | null { + if (HEX_PATTERN.test(colour)) { + const value = Number.parseInt(colour.slice(1), 16); + + return { + channels: [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff], + alpha: null, + }; + } + + const parsed = RGB_PATTERN.exec(colour); + + if (parsed === null) { + return null; + } + + return { + channels: [Number(parsed[1]), Number(parsed[2]), Number(parsed[3])], + alpha: parsed[4] ?? null, + }; +} + +function toHex(channels: readonly number[]): string { + const digits = channels.map(channel => channel.toString(16).padStart(2, "0")); + + return `#${digits.join("")}`; +} + +/** Towards white (255) or black (0); a colour it cannot read comes back as it was. */ +function mix(colour: string, towards: number, amount: number): string { + const read = readColour(colour); + + if (read === null) { + return colour; + } + + const blended = read.channels.map(channel => Math.round(channel + (towards - channel) * amount)); + + if (read.alpha === null) { + return toHex(blended); + } + + return `rgba(${blended.join(", ")}, ${read.alpha})`; +} + +export function lightenColour(colour: string, amount: number): string { + return mix(colour, 255, amount); +} + +export function shadeColour(colour: string, amount: number): string { + return mix(colour, 0, amount); +} diff --git a/src/cosmetics/tuning/NicknameTuning.vue b/src/cosmetics/tuning/NicknameTuning.vue new file mode 100644 index 00000000..8b18a5e9 --- /dev/null +++ b/src/cosmetics/tuning/NicknameTuning.vue @@ -0,0 +1,1252 @@ + + + + + + + diff --git a/src/cosmetics/tuning/nicknameTuningEdits.ts b/src/cosmetics/tuning/nicknameTuningEdits.ts new file mode 100644 index 00000000..d1ec4a94 --- /dev/null +++ b/src/cosmetics/tuning/nicknameTuningEdits.ts @@ -0,0 +1,34 @@ +import type { NicknameStyleTuning } from "@/cosmetics/kinds/nickname-style"; + +export const WEIGHT_MIN = 100; +export const WEIGHT_MAX = 900; +export const WEIGHT_STEP = 100; +export const DEFAULT_WEIGHT = 600; + +export const SPACING_MIN = -10; +export const SPACING_MAX = 50; +export const SPACING_STEP = 1; + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} + +/** Null is "as the face draws it"; anything else is snapped to a hundred and kept within 100–900. */ +export function withWeight(tuning: NicknameStyleTuning, weight: number | null): NicknameStyleTuning { + if (weight === null || !Number.isFinite(weight)) { + return { ...tuning, weight: null }; + } + + const snapped = Math.round(weight / WEIGHT_STEP) * WEIGHT_STEP; + + return { ...tuning, weight: clamp(snapped, WEIGHT_MIN, WEIGHT_MAX) }; +} + +/** Null is the face's own spacing; anything else is whole hundredths of an em within −10…50. */ +export function withLetterSpacing(tuning: NicknameStyleTuning, centiEm: number | null): NicknameStyleTuning { + if (centiEm === null || !Number.isFinite(centiEm)) { + return { ...tuning, letterSpacingCentiEm: null }; + } + + return { ...tuning, letterSpacingCentiEm: clamp(Math.round(centiEm), SPACING_MIN, SPACING_MAX) }; +} diff --git a/test/components/CosmeticPickerDialog.test.ts b/test/components/CosmeticPickerDialog.test.ts index 0989fa60..6f22536e 100644 --- a/test/components/CosmeticPickerDialog.test.ts +++ b/test/components/CosmeticPickerDialog.test.ts @@ -1,7 +1,8 @@ /** * 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. + * for a catalogue kind, a WornNickname for the bare nickname look — and nothing at all while the + * draft holds a row this account may not wear. Taking a tiled kind off is a draft too, sent as an + * unequip; taking a bare kind off is written at once. */ import { @@ -112,12 +113,47 @@ vi.mock("@/components/settings/cosmetics/CosmeticCell.vue", async () => { return { default: defineComponent({ setup: () => () => renderNode("span") }) }; }); +vi.mock("@/cosmetics/tuning/NicknameTuning.vue", async () => { + const { defineComponent, h: renderNode } = await import("vue"); + + return { + default: defineComponent({ + name: "NicknameTuning", + props: { tuning: { type: Object, required: true }, aimable: Boolean, disabled: Boolean }, + emits: ["update:tuning"], + setup: () => () => renderNode("div", { class: "tuning-stub" }), + }), + }; +}); +vi.mock("@/components/settings/cosmetics/NicknameAxes.vue", async () => { + const { defineComponent, h: renderNode } = await import("vue"); + + return { + default: defineComponent({ + name: "NicknameAxes", + props: { + kind: { type: Object, required: true }, + axes: { type: Object, required: true }, + tuning: { type: Object, required: true }, + wearable: { type: Function, required: true }, + sample: { type: String, required: true }, + disabled: Boolean, + }, + emits: ["update:axes"], + setup: () => () => renderNode("div", { class: "picker-axis" }), + }), + }; +}); import { FrameRing, + NicknameGradientShape, PayloadAvatarDecoration, + PayloadFont, PayloadProfileFrame, + PayloadTextEffect, WornItem, + WornNickname, type ArgonUserProfile, type CatalogueCosmetic, type CosmeticCatalogue, @@ -125,6 +161,8 @@ import { type IWornCosmetic, } from "@argon/glue"; +import type { NicknameAxes } from "@/cosmetics/wire"; +import type { NicknameStyleTuning } from "@/cosmetics/kinds/nickname-style"; import type { WardrobeChange } from "@/components/settings/cosmetics/wardrobeChange"; import CosmeticPickerDialog from "@/components/settings/cosmetics/CosmeticPickerDialog.vue"; import { resolveKind } from "@/cosmetics/registry"; @@ -135,6 +173,8 @@ 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 FONT = "aaaaaaaa-0000-0000-0000-000000000005"; +const LOCKED_GLOW = "aaaaaaaa-0000-0000-0000-000000000006"; const ring = new FrameRing(true, 100, null, null, 2, [0xff00ff00 | 0], 90, 0); @@ -164,12 +204,14 @@ function row( } const catalogue: CosmeticCatalogue = { - enabledKinds: ["avatar.decoration", "profile.frame"], + enabledKinds: ["avatar.decoration", "profile.frame", "nickname.style", "option.font", "option.text-effect"], 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 }), + row(FONT, "option.font", "lato", new PayloadFont("Lato"), { free: true }), + row(LOCKED_GLOW, "option.text-effect", "glow", new PayloadTextEffect()), ], }; @@ -232,6 +274,22 @@ function applied(wrapper: Picker): WardrobeChange[] { return (wrapper.emitted("apply") ?? []).map(args => args[0] as WardrobeChange); } +async function tune(wrapper: Picker, tuning: Partial): Promise { + const editor = wrapper.getComponent({ name: "NicknameTuning" }); + const current = editor.props("tuning") as NicknameStyleTuning; + + editor.vm.$emit("update:tuning", { ...current, ...tuning }); + await flushPromises(); +} + +async function chooseAxes(wrapper: Picker, axes: Partial): Promise { + const picker = wrapper.getComponent({ name: "NicknameAxes" }); + const current = picker.props("axes") as NicknameAxes; + + picker.vm.$emit("update:axes", { ...current, ...axes }); + await flushPromises(); +} + beforeEach(async () => { await runSessionReset(); setActivePinia(createPinia()); @@ -486,3 +544,125 @@ describe("keyboard", () => { wrapper.unmount(); }); }); + +describe("nickname look", () => { + test("opens on its axes and tuning, with no list and no caption", async () => { + const wrapper = await mountPicker("nickname.style", wearing()); + + expect(wrapper.findAll(".picker-cell")).toHaveLength(0); + expect(wrapper.find(".picker-caption").exists()).toBe(false); + expect(wrapper.findComponent({ name: "NicknameAxes" }).exists()).toBe(true); + expect(wrapper.findComponent({ name: "NicknameTuning" }).exists()).toBe(true); + + wrapper.unmount(); + }); + + test("a font and a colour are applied as one WornNickname", async () => { + const wrapper = await mountPicker("nickname.style", wearing()); + + await chooseAxes(wrapper, { font: FONT }); + await tune(wrapper, { colors: [0xffff0000 | 0] }); + await applyButton(wrapper).trigger("click"); + + const [change] = applied(wrapper); + + expect(change.action).toBe("equip"); + expect(change.kindKey).toBe("nickname.style"); + + const worn = (change as Extract).worn as WornNickname; + + expect(worn).toBeInstanceOf(WornNickname); + expect(worn.font).toBe(FONT); + expect(worn.effect).toBeNull(); + expect(worn.colors).toHaveLength(1); + + wrapper.unmount(); + }); + + test("a locked effect in the draft blocks apply and says why", async () => { + const wrapper = await mountPicker("nickname.style", wearing()); + + await chooseAxes(wrapper, { effect: LOCKED_GLOW }); + + expect(applyButton(wrapper).attributes("disabled")).toBeDefined(); + expect(wrapper.get(".picker-caption").text()).toContain("cosmetic_locked_grant"); + + wrapper.unmount(); + }); + + test("a direction is only offered once there is something to aim", async () => { + const wrapper = await mountPicker("nickname.style", wearing()); + const editor = () => wrapper.getComponent({ name: "NicknameTuning" }); + + expect(editor().props("aimable")).toBe(false); + + await tune(wrapper, { colors: [0xffff0000 | 0, 0xff0000ff | 0] }); + + expect(editor().props("aimable")).toBe(true); + + wrapper.unmount(); + }); + + test("editing one field of a fully worn look keeps every other field", async () => { + h.getMyCosmetics.mockResolvedValue({ owned: [{ cosmeticId: LOCKED_GLOW, expiresAt: null, viaSubscription: false }] }); + + const worn = new WornNickname(FONT, LOCKED_GLOW, [0xffff0000 | 0, 0xff0000ff | 0], 45, NicknameGradientShape.Radial, true, 700, 5); + const wrapper = await mountPicker("nickname.style", wearing(worn)); + + await tune(wrapper, { colors: [0xffff0000 | 0, 0xff0000ff | 0, 0xff00ff00 | 0] }); + await applyButton(wrapper).trigger("click"); + + const [change] = applied(wrapper); + const sent = (change as Extract).worn as WornNickname; + + expect(sent.colors).toHaveLength(3); + expect(sent.font).toBe(FONT); + expect(sent.effect).toBe(LOCKED_GLOW); + expect(sent.angle).toBe(45); + expect(sent.shape).toBe(NicknameGradientShape.Radial); + expect(sent.animate).toBe(true); + expect(sent.weight).toBe(700); + expect(sent.letterSpacing).toBe(5); + + wrapper.unmount(); + }); + + test("an axis row the catalogue no longer has is dropped from the next Apply", async () => { + const worn = new WornNickname("cccccccc-0000-0000-0000-000000000000", null, [0xffff0000 | 0], null, null, null, null, null); + const wrapper = await mountPicker("nickname.style", wearing(worn)); + + expect(applyButton(wrapper).attributes("disabled")).toBeDefined(); + + await tune(wrapper, { colors: [0xffff0000 | 0, 0xff0000ff | 0] }); + await applyButton(wrapper).trigger("click"); + + const [change] = applied(wrapper); + + expect(((change as Extract).worn as WornNickname).font).toBeNull(); + + wrapper.unmount(); + }); + + test("taking the look off is written at once, since being on is all a bare kind records", async () => { + const worn = new WornNickname(FONT, null, [0xffff0000 | 0], null, null, null, 700, null); + const wrapper = await mountPicker("nickname.style", wearing(worn)); + + await buttonSaying(wrapper, "cosmetic_clear")!.trigger("click"); + + expect(applied(wrapper)).toEqual([{ action: "unequip", kindKey: "nickname.style" }]); + + wrapper.unmount(); + }); + + test("clearing everything from a worn look takes it off", async () => { + const worn = new WornNickname(null, null, [0xffff0000 | 0], null, null, null, null, null); + const wrapper = await mountPicker("nickname.style", wearing(worn)); + + await tune(wrapper, { colors: null }); + await applyButton(wrapper).trigger("click"); + + expect(applied(wrapper)).toEqual([{ action: "unequip", kindKey: "nickname.style" }]); + + wrapper.unmount(); + }); +}); diff --git a/test/components/CosmeticsPanel.test.ts b/test/components/CosmeticsPanel.test.ts index a587232d..72edeb8c 100644 --- a/test/components/CosmeticsPanel.test.ts +++ b/test/components/CosmeticsPanel.test.ts @@ -93,6 +93,7 @@ vi.mock("@/components/settings/cosmetics/CosmeticPickerDialog.vue", async () => import { CosmeticError, WornItem, + WornNickname, type CatalogueCosmetic, } from "@argon/glue"; @@ -105,7 +106,7 @@ import { const FRAME = "aaaaaaaa-0000-0000-0000-000000000001"; -const KIND_INDEX: Record = { "profile.frame": 0 }; +const KIND_INDEX: Record = { "profile.frame": 0, "nickname.style": 1 }; function frameRow(): CatalogueCosmetic { return { @@ -186,10 +187,12 @@ describe("worn label", () => { expect(wornLabelOf(wrapper, "profile.frame")).toBe("none"); }); - test("a bare kind is not offered", async () => { + test("a bare kind worn says it is configured", async () => { + h.worn.set("nickname.style", new WornNickname(null, null, null, null, null, null, null, null)); + const wrapper = await mountPanel(); - expect(wrapper.findAll(".worn-row")).toHaveLength(1); + expect(wornLabelOf(wrapper, "nickname.style")).toBe("cosmetic_configured"); }); }); @@ -299,7 +302,7 @@ describe("applying from the picker", () => { await send(wrapper, frameChange("a")); expect(picker(wrapper).props("busy")).toBe(true); - expect(rowOf(wrapper, "profile.frame").attributes("disabled")).toBeDefined(); + expect(rowOf(wrapper, "nickname.style").attributes("disabled")).toBeDefined(); await send(wrapper, frameChange("b")); diff --git a/test/components/NicknameAxes.test.ts b/test/components/NicknameAxes.test.ts new file mode 100644 index 00000000..e2899329 --- /dev/null +++ b/test/components/NicknameAxes.test.ts @@ -0,0 +1,238 @@ +/** + * The nickname's axes as the original picker drew them — one row of chips per axis, a reset beside + * the heading, a locked option padlocked and not pressable — with each axis a single radio group: + * one tab stop, arrows / Home / End that move and choose, and a locked option that says so. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { + createPinia, + setActivePinia, +} from "pinia"; +import { + PayloadFont, + PayloadTextEffect, + type CatalogueCosmetic, + type CosmeticCatalogue, + type ICosmeticPayload, +} from "@argon/glue"; +import { + mount, + type DOMWrapper, + type VueWrapper, +} from "@vue/test-utils"; + +import NicknameAxes from "@/components/settings/cosmetics/NicknameAxes.vue"; +import type { NicknameAxes as NicknameAxesValue } from "@/cosmetics/wire"; +import { resolveKind } from "@/cosmetics/registry"; +import { runSessionReset } from "@/store/system/sessionLifecycle"; +import { useCosmeticsStore } from "@/store/features/cosmeticsStore"; + +const mocks = vi.hoisted(() => ({ + getCatalogue: vi.fn(), +})); + +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: mocks.getCatalogue, + GetMyCosmetics: async () => ({ owned: [] }), + }, + }), +})); +vi.mock("@/store/auth/meStore", () => ({ useMe: () => ({ me: { userId: "me" }, meProfile: null }) })); +vi.mock("@/store/data/profileCacheStore", () => ({ useProfileCacheStore: () => ({}) })); +vi.mock("@/store/system/localeStore", async () => { + const { ref } = await import("vue"); + const currentLocale = ref("en"); + + return { + useLocale: () => ({ t: (key: string) => key, currentLocale }), + documentLanguage: (locale: string) => locale, + }; +}); +vi.mock("@/store/system/fileStorage", () => ({ + cdnUrl: (fileId: string) => `cdn:${fileId}`, + cdnCrossOrigin: () => undefined, +})); +vi.mock("@/cosmetics/chips/optionChips", () => ({ chipForOptionKind: () => null })); + +const LATO = "aaaaaaaa-0000-0000-0000-000000000004"; +const MONO = "aaaaaaaa-0000-0000-0000-000000000005"; +const GLOW = "aaaaaaaa-0000-0000-0000-000000000006"; +const SERIF = "aaaaaaaa-0000-0000-0000-000000000007"; + +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: true, + ultima: false, + text: [{ locale: "en", name: slug, description: null }], + ...overrides, + }; +} + +const catalogue: CosmeticCatalogue = { + enabledKinds: ["nickname.style", "option.font", "option.text-effect"], + items: [ + row(LATO, "option.font", "lato", new PayloadFont("Lato")), + row(MONO, "option.font", "mono", new PayloadFont("Mono"), { free: false }), + row(SERIF, "option.font", "serif", new PayloadFont("Serif")), + row(GLOW, "option.text-effect", "glow", new PayloadTextEffect()), + ], +}; + +function wearable(item: CatalogueCosmetic): boolean { + return item.free; +} + +function tabStops(radios: DOMWrapper[]): number[] { + const stops: number[] = []; + + for (let index = 0; index < radios.length; index++) { + if (radios[index].attributes("tabindex") === "0") { + stops.push(index); + } + } + + return stops; +} + +beforeEach(async () => { + await runSessionReset(); + + setActivePinia(createPinia()); + + mocks.getCatalogue.mockReset(); + mocks.getCatalogue.mockResolvedValue(catalogue); + + await useCosmeticsStore().ensureLoaded(); +}); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("nickname axes", () => { + function mountAxes(axes: NicknameAxesValue, props: Record = {}) { + return mount(NicknameAxes, { + props: { + kind: resolveKind("nickname.style")!, + axes, + tuning: { colors: [], angle: null, shape: null, animate: null, weight: null, letterSpacingCentiEm: null }, + wearable, + sample: "Omni", + ...props, + }, + attachTo: document.body, + }); + } + + function fontRadios(wrapper: VueWrapper): DOMWrapper[] { + return wrapper.findAll('[role="radiogroup"]')[0].findAll('[role="radio"]'); + } + + function effectRadios(wrapper: VueWrapper): DOMWrapper[] { + return wrapper.findAll('[role="radiogroup"]')[1].findAll('[role="radio"]'); + } + + function axesEmitted(wrapper: VueWrapper): unknown[] { + const emitted = wrapper.emitted("update:axes") ?? []; + const values: unknown[] = []; + + for (const args of emitted) { + values.push(args[0]); + } + + return values; + } + + test("each axis has one tab stop: its chosen option, or the first that can be chosen", () => { + const wrapper = mountAxes({ font: SERIF, effect: null }); + + expect(tabStops(fontRadios(wrapper))).toEqual([1]); + expect(tabStops(effectRadios(wrapper))).toEqual([0]); + }); + + test("arrows, Home and End choose along one axis, skip what is locked and leave the other alone", async () => { + const wrapper = mountAxes({ font: null, effect: GLOW }); + + await fontRadios(wrapper)[0].trigger("keydown", { key: "ArrowRight" }); + + expect(document.activeElement).toBe(fontRadios(wrapper)[1].element); + + await fontRadios(wrapper)[1].trigger("keydown", { key: "End" }); + await fontRadios(wrapper)[1].trigger("keydown", { key: "Home" }); + await fontRadios(wrapper)[0].trigger("keydown", { key: "ArrowLeft" }); + + expect(axesEmitted(wrapper)).toEqual([ + { font: SERIF, effect: GLOW }, + { font: SERIF, effect: GLOW }, + { font: LATO, effect: GLOW }, + { font: SERIF, effect: GLOW }, + ]); + expect(document.activeElement).toBe(fontRadios(wrapper)[1].element); + }); + + test("clicking the chosen option takes it off, and so does the axis's reset", async () => { + const wrapper = mountAxes({ font: LATO, effect: GLOW }); + + await fontRadios(wrapper)[0].trigger("click"); + await wrapper.findAll(".picker-axis-clear")[1].trigger("click"); + + expect(axesEmitted(wrapper)).toEqual([ + { font: null, effect: GLOW }, + { font: LATO, effect: null }, + ]); + }); + + test("a locked option cannot be chosen and says so in its name", () => { + const wrapper = mountAxes({ font: MONO, effect: null }); + const mono = fontRadios(wrapper)[2]; + + expect(fontRadios(wrapper)[0].attributes("aria-label")).toBe("lato"); + expect(mono.attributes("aria-label")).toBe("mono, cosmetic_locked"); + expect(mono.attributes("aria-checked")).toBe("true"); + expect(mono.attributes("disabled")).toBeDefined(); + expect(mono.find(".picker-lock").exists()).toBe(true); + }); + + test("busy, nothing on the axes can be pressed", () => { + const wrapper = mountAxes({ font: LATO, effect: null }, { disabled: true }); + + for (const radio of [...fontRadios(wrapper), ...effectRadios(wrapper)]) { + expect(radio.attributes("disabled")).toBeDefined(); + } + + expect(wrapper.get(".picker-axis-clear").attributes("disabled")).toBeDefined(); + }); +}); diff --git a/test/components/NicknameTuning.test.ts b/test/components/NicknameTuning.test.ts new file mode 100644 index 00000000..a90af91a --- /dev/null +++ b/test/components/NicknameTuning.test.ts @@ -0,0 +1,493 @@ +/** + * The nickname tuning editor: colours dragged into order with the pointer (or Alt + arrows), no + * seventh colour, weight in hundreds within 100–900, spacing within −10…50, the dial on a circle, + * and the shapes one radio group. + */ + +import { + afterEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { + flushPromises, + mount, + type DOMWrapper, + type VueWrapper, +} from "@vue/test-utils"; + +vi.mock("@/store/system/localeStore", () => ({ + useLocale: () => ({ t: (key: string) => key }), +})); +vi.mock("@/composables/useReducedMotion", async () => { + const { ref } = await import("vue"); + + return { reduceMotion: ref(false) }; +}); + +import { + MAX_STOPS, + emptyNicknameStyleTuning, + type NicknameStyleTuning, +} from "@/cosmetics/kinds/nickname-style"; +import NicknameTuning from "@/cosmetics/tuning/NicknameTuning.vue"; + +const RED = 0xffff0000 | 0; +const GREEN = 0xff00ff00 | 0; +const BLUE = 0xff0000ff | 0; + +/** Where each colour sits on screen in these tests: 30px wells, 7px apart, on one row. */ +const WELL = 30; +const GAP = 7; + +const attached: VueWrapper[] = []; + +function tuningOf(fields: Partial): NicknameStyleTuning { + return { ...emptyNicknameStyleTuning(), ...fields }; +} + +/** Mounted on the page and fed its own writes back, the way a parent's v-model would. */ +function mountLive(tuning: NicknameStyleTuning, props: Record = {}) { + const wrapper: VueWrapper = mount(NicknameTuning, { + attachTo: document.body, + props: { + tuning, + ...props, + "onUpdate:tuning": (next: NicknameStyleTuning) => wrapper.setProps({ tuning: next }), + }, + }); + + attached.push(wrapper); + + return wrapper; +} + +/** Mounted with nothing fed back, so a raw value planted on an input is not patched away. */ +function mountStill(tuning: NicknameStyleTuning) { + const wrapper: VueWrapper = mount(NicknameTuning, { attachTo: document.body, props: { tuning } }); + + attached.push(wrapper); + + return wrapper; +} + +function lastEmitted(wrapper: VueWrapper): NicknameStyleTuning { + const emitted = wrapper.emitted("update:tuning") ?? []; + + return emitted.at(-1)?.[0] as NicknameStyleTuning; +} + +function box(left: number, top: number, width: number, height: number): DOMRect { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + x: left, + y: top, + toJSON: () => ({}), + }; +} + +/** + * Lays the row out the way the stylesheet would, since happy-dom computes no geometry, and stands + * in for pointer capture, which it does not implement. + */ +function layOut(wrapper: VueWrapper): { captured: number[]; released: number[] } { + const row = wrapper.get(".tuning-stops").element as HTMLElement; + const captured: number[] = []; + const released: number[] = []; + + row.getBoundingClientRect = () => { + const count = row.querySelectorAll(".stop").length; + + return box(0, 0, count * (WELL + GAP), WELL); + }; + row.setPointerCapture = (pointerId: number) => { + captured.push(pointerId); + }; + row.releasePointerCapture = (pointerId: number) => { + released.push(pointerId); + }; + + const place = () => { + const wells = row.querySelectorAll(".stop"); + + for (let index = 0; index < wells.length; index++) { + wells[index].getBoundingClientRect = () => box(index * (WELL + GAP), 0, WELL, WELL); + } + }; + + place(); + new MutationObserver(place).observe(row, { childList: true, subtree: true }); + + return { captured, released }; +} + +function centreOf(index: number): number { + return index * (WELL + GAP) + WELL / 2; +} + +function stops(wrapper: VueWrapper): DOMWrapper[] { + return wrapper.findAll(".stop"); +} + +function pointer(type: string, clientX: number, clientY = WELL / 2): PointerEvent { + return new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 7, clientX, clientY }); +} + +afterEach(() => { + for (const wrapper of attached.splice(0)) { + wrapper.unmount(); + } +}); + +describe("NicknameTuning — reordering", () => { + test("dragging a colour along the row puts it in the slot it is aimed at, one slot at a time", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, GREEN, BLUE] })); + const { captured, released } = layOut(wrapper); + const row = wrapper.get(".tuning-stops").element; + + stops(wrapper)[0].element.dispatchEvent(pointer("pointerdown", centreOf(0))); + row.dispatchEvent(pointer("pointermove", centreOf(1))); + await flushPromises(); + + expect(captured).toEqual([7]); + expect(lastEmitted(wrapper).colors).toEqual([GREEN, RED, BLUE]); + expect(stops(wrapper)[1].classes()).toContain("stop--held"); + expect(wrapper.get(".tuning-stops").classes()).toContain("tuning-stops--moving"); + + row.dispatchEvent(pointer("pointermove", centreOf(2))); + await flushPromises(); + + expect(lastEmitted(wrapper).colors).toEqual([GREEN, BLUE, RED]); + expect(stops(wrapper)[2].classes()).toContain("stop--held"); + + row.dispatchEvent(pointer("pointerup", centreOf(2))); + await flushPromises(); + + expect(released).toEqual([7]); + expect(wrapper.find(".stop--held").exists()).toBe(false); + expect(wrapper.get(".tuning-stops").classes()).not.toContain("tuning-stops--moving"); + }); + + test("a press that barely moves is a click on the colour picker, not a drag", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, GREEN] })); + const { captured } = layOut(wrapper); + const row = wrapper.get(".tuning-stops").element; + + stops(wrapper)[0].element.dispatchEvent(pointer("pointerdown", centreOf(0))); + row.dispatchEvent(pointer("pointermove", centreOf(0) + 4)); + row.dispatchEvent(pointer("pointerup", centreOf(0) + 4)); + await flushPromises(); + + expect(captured).toEqual([]); + expect(wrapper.emitted("update:tuning")).toBeUndefined(); + + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + + stops(wrapper)[0].element.dispatchEvent(click); + + expect(click.defaultPrevented).toBe(false); + }); + + test("the click a drag ends in does not open the picker it was released over", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, GREEN] })); + + layOut(wrapper); + + const row = wrapper.get(".tuning-stops").element; + + stops(wrapper)[0].element.dispatchEvent(pointer("pointerdown", centreOf(0))); + row.dispatchEvent(pointer("pointermove", centreOf(1))); + row.dispatchEvent(pointer("pointerup", centreOf(1))); + await flushPromises(); + + const click = new MouseEvent("click", { bubbles: true, cancelable: true }); + + stops(wrapper)[1].get(".stop-well").element.dispatchEvent(click); + + expect(click.defaultPrevented).toBe(true); + }); + + test("a pointer wandered far from the row aims at nothing", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, GREEN] })); + + layOut(wrapper); + + const row = wrapper.get(".tuning-stops").element; + + stops(wrapper)[0].element.dispatchEvent(pointer("pointerdown", centreOf(0))); + row.dispatchEvent(pointer("pointermove", centreOf(1), 200)); + await flushPromises(); + + expect(wrapper.emitted("update:tuning")).toBeUndefined(); + expect(stops(wrapper)[0].classes()).toContain("stop--held"); + }); + + test("a press on the little cross removes rather than drags", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, GREEN, BLUE] })); + const { captured } = layOut(wrapper); + const cross = stops(wrapper)[0].get(".stop-drop").element; + + cross.dispatchEvent(pointer("pointerdown", centreOf(0))); + wrapper.get(".tuning-stops").element.dispatchEvent(pointer("pointermove", centreOf(2))); + await flushPromises(); + + expect(captured).toEqual([]); + + await stops(wrapper)[0].get(".stop-drop").trigger("click"); + + expect(lastEmitted(wrapper).colors).toEqual([GREEN, BLUE]); + }); + + test("disabled, nothing can be picked up", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, GREEN] }), { disabled: true }); + const { captured } = layOut(wrapper); + + stops(wrapper)[0].element.dispatchEvent(pointer("pointerdown", centreOf(0))); + wrapper.get(".tuning-stops").element.dispatchEvent(pointer("pointermove", centreOf(1))); + await flushPromises(); + + expect(captured).toEqual([]); + expect(wrapper.emitted("update:tuning")).toBeUndefined(); + }); + + test("Alt and an arrow move a colour, and focus goes with it", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, GREEN, BLUE] })); + const wells = () => wrapper.findAll(".stop-well"); + + wells()[0].element.focus(); + + await wells()[0].trigger("keydown", { key: "ArrowRight", altKey: true }); + await flushPromises(); + + expect(document.activeElement).toBe(wells()[1].element); + + await wells()[1].trigger("keydown", { key: "ArrowRight", altKey: true }); + await flushPromises(); + + expect(document.activeElement).toBe(wells()[2].element); + expect(lastEmitted(wrapper).colors).toEqual([GREEN, BLUE, RED]); + + await wells()[2].trigger("keydown", { key: "ArrowRight", altKey: true }); + await flushPromises(); + + expect(wrapper.emitted("update:tuning")).toHaveLength(2); + expect(document.activeElement).toBe(wells()[2].element); + }); +}); + +describe("NicknameTuning — colours", () => { + test("colours can be added up to six, then the add button goes", async () => { + const five = mountLive(tuningOf({ colors: Array(MAX_STOPS - 1).fill(RED) })); + + await five.get(".stop-add").trigger("click"); + + expect(lastEmitted(five).colors).toHaveLength(MAX_STOPS); + expect(five.findAll(".stop-well")).toHaveLength(MAX_STOPS); + expect(five.find(".stop-add").exists()).toBe(false); + }); + + test("a colour well writes a signed ARGB colour", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED] })); + + await wrapper.get(".stop-well").setValue("#00ff00"); + + expect(lastEmitted(wrapper).colors).toEqual([GREEN]); + }); + + test("removing the last colour is no colours of one's own, and the aim stays for an effect", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED], angle: 45 })); + + await wrapper.get(".stop-drop").trigger("click"); + + expect(lastEmitted(wrapper)).toMatchObject({ colors: null, angle: 45 }); + }); +}); + +describe("NicknameTuning — the dial and the shape", () => { + test("the dial turns in steps from the keyboard and wraps round", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, RED], angle: 0 })); + const dial = wrapper.get(".dial"); + + expect(dial.attributes("role")).toBe("slider"); + expect(dial.attributes("aria-valuetext")).toBe("0°"); + + await dial.trigger("keydown", { key: "ArrowLeft" }); + + expect(lastEmitted(wrapper).angle).toBe(345); + + await dial.trigger("keydown", { key: "PageUp" }); + + expect(lastEmitted(wrapper).angle).toBe(75); + + await dial.trigger("keydown", { key: "Home" }); + + expect(lastEmitted(wrapper).angle).toBe(0); + + await dial.trigger("keydown", { key: "End" }); + + expect(lastEmitted(wrapper).angle).toBe(345); + expect(dial.attributes("aria-valuemax")).toBe("345"); + }); + + test("no direction is offered while there is nothing to aim, and one is under an effect", () => { + const flat = mountLive(tuningOf({ colors: [RED] })); + + expect(flat.find(".dial").exists()).toBe(false); + + const aimed = mountLive(tuningOf({ colors: [RED] }), { aimable: true }); + + expect(aimed.find(".dial").exists()).toBe(true); + }); + + test("a radial gradient has nothing for the dial to aim", () => { + const wrapper = mountLive(tuningOf({ colors: [RED, BLUE], shape: "radial" })); + + expect(wrapper.find(".dial").exists()).toBe(false); + expect(wrapper.find(".seg").exists()).toBe(true); + }); + + test("disabled, the dial says so and writes nothing", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, RED], angle: 0 }), { disabled: true }); + const dial = wrapper.get(".dial"); + + expect(dial.attributes("aria-disabled")).toBe("true"); + expect(dial.classes()).toContain("dial--off"); + + await dial.trigger("keydown", { key: "ArrowRight" }); + await dial.trigger("keydown", { key: "End" }); + + expect(wrapper.emitted("update:tuning")).toBeUndefined(); + }); + + test("the shapes are one tab stop, and arrows, Home and End choose and take focus", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, BLUE] })); + const radio = (index: number) => wrapper.findAll(".seg-key")[index]; + + expect(wrapper.find('[role="radiogroup"]').exists()).toBe(true); + expect(radio(0).attributes("tabindex")).toBe("0"); + expect(radio(1).attributes("tabindex")).toBe("-1"); + + radio(0).element.focus(); + + await radio(0).trigger("keydown", { key: "ArrowRight" }); + + expect(lastEmitted(wrapper).shape).toBe("radial"); + expect(document.activeElement).toBe(radio(1).element); + expect(radio(1).attributes("aria-checked")).toBe("true"); + expect(radio(1).attributes("tabindex")).toBe("0"); + + await radio(1).trigger("keydown", { key: "End" }); + + expect(lastEmitted(wrapper).shape).toBe("conic"); + expect(document.activeElement).toBe(radio(2).element); + + await radio(2).trigger("keydown", { key: "ArrowDown" }); + + expect(lastEmitted(wrapper).shape).toBe("linear"); + + await radio(0).trigger("keydown", { key: "Home" }); + + expect(lastEmitted(wrapper).shape).toBe("linear"); + expect(document.activeElement).toBe(radio(0).element); + }); + + test("travelling only switches on along a line", async () => { + const wrapper = mountLive(tuningOf({ colors: [RED, BLUE] })); + + await wrapper.get(".live").trigger("click"); + + expect(lastEmitted(wrapper).animate).toBe(true); + expect(wrapper.get(".live").classes()).toContain("live--on"); + expect(wrapper.find(".ribbon-sheen").exists()).toBe(true); + + await wrapper.findAll(".seg-key")[2].trigger("click"); + + expect(wrapper.get(".live").attributes("disabled")).toBeDefined(); + expect(wrapper.get(".live").classes()).not.toContain("live--on"); + }); +}); + +describe("NicknameTuning — weight and spacing", () => { + function enterRaw(input: Pick, "element" | "trigger">, value: string): Promise { + Object.defineProperty(input.element, "value", { configurable: true, get: () => value }); + + return input.trigger("input"); + } + + test("weight snaps to hundreds and stays within 100–900", async () => { + const wrapper = mountStill(emptyNicknameStyleTuning()); + const weight =() => wrapper.get(".gauge-range--weight"); + + expect(weight().attributes("min")).toBe("100"); + expect(weight().attributes("max")).toBe("900"); + expect(weight().attributes("step")).toBe("100"); + + await enterRaw(weight(), "450"); + expect(lastEmitted(wrapper).weight).toBe(500); + + await enterRaw(weight(), "2000"); + expect(lastEmitted(wrapper).weight).toBe(900); + + await enterRaw(weight(), "-300"); + expect(lastEmitted(wrapper).weight).toBe(100); + }); + + test("letter spacing stays within −10…50 hundredths of an em", async () => { + const wrapper = mountStill(emptyNicknameStyleTuning()); + const spacing =() => wrapper.get(".gauge-range--spacing"); + + expect(spacing().attributes("min")).toBe("-10"); + expect(spacing().attributes("max")).toBe("50"); + + await enterRaw(spacing(), "-30"); + expect(lastEmitted(wrapper).letterSpacingCentiEm).toBe(-10); + + await enterRaw(spacing(), "80"); + expect(lastEmitted(wrapper).letterSpacingCentiEm).toBe(50); + + await enterRaw(spacing(), "17.4"); + expect(lastEmitted(wrapper).letterSpacingCentiEm).toBe(17); + }); + + test("the ranges read out their value, or that it is the default", () => { + const unset = mountLive(emptyNicknameStyleTuning()); + + expect(unset.get(".gauge-range--weight").attributes("aria-valuetext")).toBe("cosmetic_tuning_default"); + expect(unset.get(".gauge-range--spacing").attributes("aria-valuetext")).toBe("cosmetic_tuning_default"); + expect(unset.find(".gauge-reset").exists()).toBe(false); + + const set = mountLive(tuningOf({ weight: 700, letterSpacingCentiEm: 12 })); + + expect(set.get(".gauge-range--weight").attributes("aria-valuetext")).toBe("700"); + expect(set.get(".gauge-range--spacing").attributes("aria-valuetext")).toBe("0.12 em"); + }); + + test("each reset says what it resets", async () => { + const wrapper = mountLive(tuningOf({ weight: 700, letterSpacingCentiEm: 12 })); + const resets = wrapper.findAll(".gauge-reset"); + + expect(resets[0].attributes("aria-label")).toBe("cosmetic_tuning_reset_weight"); + expect(resets[1].attributes("aria-label")).toBe("cosmetic_tuning_reset_spacing"); + + await resets[1].trigger("click"); + + expect(lastEmitted(wrapper)).toMatchObject({ weight: 700, letterSpacingCentiEm: null }); + }); + + test("busy, every control is disabled", () => { + const wrapper = mountLive(tuningOf({ colors: [RED, BLUE], weight: 700 }), { disabled: true }); + + for (const selector of [".stop-well", ".stop-drop", ".stop-add", ".seg-key", ".live", ".gauge-range", ".gauge-reset"]) { + for (const control of wrapper.findAll(selector)) { + expect(control.attributes("disabled"), selector).toBeDefined(); + } + } + }); +}); diff --git a/test/cosmetics/colourMix.test.ts b/test/cosmetics/colourMix.test.ts new file mode 100644 index 00000000..4be03e7b --- /dev/null +++ b/test/cosmetics/colourMix.test.ts @@ -0,0 +1,31 @@ +import { + describe, + expect, + it, +} from "vitest"; + +import { + lightenColour, + shadeColour, +} from "@/cosmetics/primitives/colourMix"; + +describe("colour mixing", () => { + it("reads hex and rgb() and gives hex back", () => { + expect(lightenColour("#000000", 0.5)).toBe("#808080"); + expect(shadeColour("rgb(200, 100, 50)", 0.5)).toBe("#643219"); + }); + + it("keeps the alpha of an rgba() colour exactly as written", () => { + expect(shadeColour("rgba(200, 100, 50, 0.50)", 0.5)).toBe("rgba(100, 50, 25, 0.50)"); + expect(lightenColour("rgba(0, 0, 0, 0.25)", 0.5)).toBe("rgba(128, 128, 128, 0.25)"); + }); + + it("keeps an alpha written the modern way", () => { + expect(shadeColour("rgb(200 100 50 / 40%)", 0.5)).toBe("rgba(100, 50, 25, 40%)"); + }); + + it("gives back anything it cannot read", () => { + expect(lightenColour("var(--role)", 0.5)).toBe("var(--role)"); + expect(shadeColour("rgba(1, 2, 3, 0.5) trailing", 0.5)).toBe("rgba(1, 2, 3, 0.5) trailing"); + }); +}); diff --git a/test/cosmetics/nicknameTuningEdits.test.ts b/test/cosmetics/nicknameTuningEdits.test.ts new file mode 100644 index 00000000..310831d5 --- /dev/null +++ b/test/cosmetics/nicknameTuningEdits.test.ts @@ -0,0 +1,49 @@ +/** + * The bounds a wearer's weight and letter spacing are held to before they reach the server: weight + * 100–900 in hundreds, letter spacing −10…50 hundredths of an em. Anything past them would come + * back as VALUE_OUT_OF_RANGE. + */ + +import { + describe, + expect, + test, +} from "vitest"; + +import { + emptyNicknameStyleTuning, + parseNicknameStyleTuning, +} from "@/cosmetics/kinds/nickname-style"; +import { + withLetterSpacing, + withWeight, +} from "@/cosmetics/tuning/nicknameTuningEdits"; + +describe("weight", () => { + test("snaps to hundreds within 100–900", () => { + const tuning = emptyNicknameStyleTuning(); + + expect(withWeight(tuning, 640).weight).toBe(600); + expect(withWeight(tuning, 20).weight).toBe(100); + expect(withWeight(tuning, 1500).weight).toBe(900); + expect(withWeight(tuning, Number.NaN).weight).toBeNull(); + expect(withWeight(withWeight(tuning, 700), null).weight).toBeNull(); + }); +}); + +describe("letter spacing", () => { + test("whole hundredths of an em within −10…50", () => { + const tuning = emptyNicknameStyleTuning(); + + expect(withLetterSpacing(tuning, 12.4).letterSpacingCentiEm).toBe(12); + expect(withLetterSpacing(tuning, -40).letterSpacingCentiEm).toBe(-10); + expect(withLetterSpacing(tuning, 80).letterSpacingCentiEm).toBe(50); + expect(withLetterSpacing(withLetterSpacing(tuning, 5), null).letterSpacingCentiEm).toBeNull(); + }); + + test("whatever the editor produces, the upstream parser accepts", () => { + const tuning = withLetterSpacing(withWeight(emptyNicknameStyleTuning(), 9999), -999); + + expect(parseNicknameStyleTuning(tuning)).toEqual(tuning); + }); +}); diff --git a/test/cosmetics/wardrobeChange.test.ts b/test/cosmetics/wardrobeChange.test.ts index 9a45f278..02c1899c 100644 --- a/test/cosmetics/wardrobeChange.test.ts +++ b/test/cosmetics/wardrobeChange.test.ts @@ -1,6 +1,7 @@ /** - * 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. + * What one Apply in the wardrobe sends. A catalogue kind wears one row (`WornItem`), a nickname is a + * bare look (`WornNickname`) composed of its axis rows and the wearer's tuning, and choosing nothing + * takes the kind off instead of saving an empty look. */ import { @@ -10,11 +11,20 @@ import { } from "vitest"; import { CosmeticError, + NicknameGradientShape, WornItem, + WornNickname, type CatalogueCosmetic, type IWornCosmetic, } from "@argon/glue"; +import { + emptyNicknameDraft, + isNicknameDraftEmpty, + nicknameChange, + nicknameDraftOf, + sameNicknameDraft, +} from "@/components/settings/cosmetics/nicknameDraft"; import { equipChange, itemChange, @@ -28,6 +38,10 @@ import { lockedAccessibleName, } from "@/components/settings/cosmetics/cosmeticLock"; +const KIND = "nickname.style"; +const FONT_ID = "aaaaaaaa-0000-0000-0000-000000000003"; +const GLOW_ID = "aaaaaaaa-0000-0000-0000-000000000004"; + function row(overrides: Partial = {}): CatalogueCosmetic { return { cosmeticId: "aaaaaaaa-0000-0000-0000-000000000001", @@ -65,6 +79,115 @@ describe("item kinds", () => { }); }); +describe("nickname look", () => { + test("axes and tuning become one WornNickname, field for field", () => { + const change = nicknameChange({ + axes: { font: FONT_ID, effect: GLOW_ID }, + tuning: { + colors: [0xffff0000 | 0, 0xff0000ff | 0], + angle: 45, + shape: "conic", + animate: null, + weight: 700, + letterSpacingCentiEm: -5, + }, + }, KIND); + + expect(change.action).toBe("equip"); + expect(change.kindKey).toBe("nickname.style"); + + const worn = wornAfter(change) as WornNickname; + + expect(worn).toBeInstanceOf(WornNickname); + expect(worn.font).toBe(FONT_ID); + expect(worn.effect).toBe(GLOW_ID); + expect(worn.colors).toEqual([0xffff0000 | 0, 0xff0000ff | 0]); + expect(worn.angle).toBe(45); + expect(worn.shape).toBe(NicknameGradientShape.Conic); + expect(worn.animate).toBeNull(); + expect(worn.weight).toBe(700); + expect(worn.letterSpacing).toBe(-5); + }); + + test("a weight alone is a look, not nothing", () => { + const draft = { ...emptyNicknameDraft(), tuning: { ...emptyNicknameDraft().tuning, weight: 300 } }; + + expect(isNicknameDraftEmpty(draft)).toBe(false); + expect(nicknameChange(draft, KIND).action).toBe("equip"); + }); + + test("nothing chosen takes the look off", () => { + expect(nicknameChange(emptyNicknameDraft(), KIND)).toEqual({ action: "unequip", kindKey: "nickname.style" }); + }); + + test("a worn look opens as the draft it was saved from", () => { + const worn = new WornNickname(FONT_ID.toUpperCase(), null, [0xff00ff00 | 0], 90, NicknameGradientShape.Linear, true, 500, 10); + const draft = nicknameDraftOf(worn); + + expect(draft.axes).toEqual({ font: FONT_ID.toUpperCase(), effect: null }); + expect(draft.tuning).toEqual({ + colors: [0xff00ff00 | 0], + angle: 90, + shape: "linear", + animate: true, + weight: 500, + letterSpacingCentiEm: 10, + }); + expect(sameNicknameDraft(draft, { ...draft, axes: { font: FONT_ID, effect: null } })).toBe(true); + expect(sameNicknameDraft(draft, { ...draft, tuning: { ...draft.tuning, weight: 600 } })).toBe(false); + }); + + test("goes out under the kind it was asked for", () => { + expect(nicknameChange(emptyNicknameDraft(), "nickname.other").kindKey).toBe("nickname.other"); + }); + + test("an aim the picker hides is not saved: a single colour with no effect drops it", () => { + const draft = { + axes: { font: FONT_ID, effect: null }, + tuning: { ...emptyNicknameDraft().tuning, colors: [0xffff0000 | 0], angle: 45, shape: "conic" as const, animate: true }, + }; + + const worn = wornAfter(nicknameChange(draft, KIND)) as WornNickname; + + expect(worn.angle).toBeNull(); + expect(worn.shape).toBeNull(); + expect(worn.animate).toBeNull(); + expect(worn.colors).toEqual([0xffff0000 | 0]); + }); + + test("an aim alone is no look, and takes it off", () => { + const draft = { + ...emptyNicknameDraft(), + tuning: { ...emptyNicknameDraft().tuning, angle: 45, shape: "radial" as const }, + }; + + expect(isNicknameDraftEmpty(draft)).toBe(true); + expect(nicknameChange(draft, KIND)).toEqual({ action: "unequip", kindKey: KIND }); + }); + + test("an aim under an effect is kept, even on one colour", () => { + const draft = { + axes: { font: null, effect: GLOW_ID }, + tuning: { ...emptyNicknameDraft().tuning, colors: [0xffff0000 | 0], angle: 45 }, + }; + + expect((wornAfter(nicknameChange(draft, KIND)) as WornNickname).angle).toBe(45); + }); + + test("an axis row the picker cannot offer is dropped from the draft, the rest is kept", () => { + const worn = new WornNickname(FONT_ID, GLOW_ID, [0xff00ff00 | 0], null, null, null, 700, null); + const draft = nicknameDraftOf(worn, axis => axis === "font"); + + expect(draft.axes).toEqual({ font: FONT_ID, effect: null }); + expect(draft.tuning.weight).toBe(700); + }); + + test("anything that is not a nickname opens empty", () => { + expect(nicknameDraftOf(new WornItem("x"))).toEqual(emptyNicknameDraft()); + expect(nicknameDraftOf(null)).toEqual(emptyNicknameDraft()); + }); +}); + describe("locks", () => { test("the caption says why", () => { expect(lockReasonOf(row({ ultima: true }), false)).toBe("ultima");