diff --git a/client-app/app-runner.ts b/client-app/app-runner.ts index 97f623a35d..83c0c769aa 100644 --- a/client-app/app-runner.ts +++ b/client-app/app-runner.ts @@ -3,7 +3,7 @@ import { DefaultApolloClient } from "@vue/apollo-composable"; import { createApp, h, provide } from "vue"; import { apolloClient, getPageContext } from "@/core/api/graphql"; import { GetSlugInfoDocument } from "@/core/api/graphql/types"; -import { useCurrency, useThemeContext, useNavigations, useWhiteLabeling } from "@/core/composables"; +import { useCurrency, useDarkMode, useThemeContext, useNavigations, useWhiteLabeling } from "@/core/composables"; import { useHotjar } from "@/core/composables/useHotjar"; import { useLanguages } from "@/core/composables/useLanguages"; import { FALLBACK_LOCALE, IS_DEVELOPMENT } from "@/core/constants"; @@ -85,6 +85,7 @@ export default async () => { const { init: initializeHotjar } = useHotjar(); const { fetchCatalogMenu } = useNavigations(); const { themePresetName, setWhiteLabelingSettings } = useWhiteLabeling(); + useDarkMode(); const fallback = { locale: FALLBACK_LOCALE, diff --git a/client-app/assets/presets/black-gold.dark.json b/client-app/assets/presets/black-gold.dark.json new file mode 100644 index 0000000000..36e01ddfbf --- /dev/null +++ b/client-app/assets/presets/black-gold.dark.json @@ -0,0 +1,127 @@ +{ + "color_primary_50": "#080809", + "color_primary_100": "#0a0b0d", + "color_primary_200": "#0c1015", + "color_primary_300": "#10151d", + "color_primary_400": "#161a20", + "color_primary_500": "#7c7e81", + "color_primary_600": "#4a4c4e", + "color_primary_700": "#77797b", + "color_primary_800": "#a6a7a9", + "color_primary_900": "#d5d6d7", + "color_primary_950": "#eeeeef", + "color_secondary_50": "#2b2926", + "color_secondary_100": "#3f3b33", + "color_secondary_200": "#685e49", + "color_secondary_300": "#9e8a67", + "color_secondary_400": "#baa06d", + "color_secondary_500": "#987836", + "color_secondary_600": "#edbe6d", + "color_secondary_700": "#f4cf90", + "color_secondary_800": "#ffe0ad", + "color_secondary_900": "#fff3de", + "color_secondary_950": "#fff8ee", + "color_accent_50": "#020306", + "color_accent_100": "#060b14", + "color_accent_200": "#121c2b", + "color_accent_300": "#1e2d44", + "color_accent_400": "#2d3e58", + "color_accent_500": "#718096", + "color_accent_600": "#606c7e", + "color_accent_700": "#818b99", + "color_accent_800": "#a4aab4", + "color_accent_900": "#dfe2e7", + "color_accent_950": "#f1f4f4", + "color_neutral_50": "#030201", + "color_neutral_100": "#120f0d", + "color_neutral_200": "#2f2b2b", + "color_neutral_300": "#63605d", + "color_neutral_400": "#696260", + "color_neutral_500": "#4e4744", + "color_neutral_600": "#b0a9a6", + "color_neutral_700": "#d5cecd", + "color_neutral_800": "#ebe4e2", + "color_neutral_900": "#f8f0f0", + "color_neutral_950": "#fef8f4", + "color_warning_50": "#292417", + "color_warning_100": "#41371b", + "color_warning_200": "#735e19", + "color_warning_300": "#977300", + "color_warning_400": "#d09213", + "color_warning_500": "#af711e", + "color_warning_600": "#ffb55b", + "color_warning_700": "#ffcd9e", + "color_warning_800": "#ffe0c6", + "color_warning_900": "#ffeee1", + "color_warning_950": "#fff7ef", + "color_danger_50": "#130c0b", + "color_danger_100": "#2d1617", + "color_danger_200": "#663133", + "color_danger_300": "#8c3438", + "color_danger_400": "#ae3135", + "color_danger_500": "#dd4741", + "color_danger_600": "#ea5e51", + "color_danger_700": "#fb7e70", + "color_danger_800": "#ffa99e", + "color_danger_900": "#ffd5ce", + "color_danger_950": "#fff2f0", + "color_success_50": "#0e1511", + "color_success_100": "#051d09", + "color_success_200": "#1c3c2b", + "color_success_300": "#2c6245", + "color_success_400": "#2f7951", + "color_success_500": "#4c8a64", + "color_success_600": "#72a886", + "color_success_700": "#91bea0", + "color_success_800": "#b5d4bf", + "color_success_900": "#d1e4d8", + "color_success_950": "#e7f7ee", + "color_info_50": "#2c3337", + "color_info_100": "#2f3b40", + "color_info_200": "#324c59", + "color_info_300": "#335d71", + "color_info_400": "#316c88", + "color_info_500": "#4086ac", + "color_info_600": "#75a7c7", + "color_info_700": "#95bdd7", + "color_info_800": "#b4d3e9", + "color_info_900": "#d5eafb", + "color_info_950": "#ebf6ff", + "color_additional_50": "#141110", + "color_additional_950": "#f0e6dd", + "color_body_bg": "#060302", + "color_body_text": "#ebe3da", + "color_link": "#ae8946", + "color_link_hover": "#938e8d", + "color_shape_icon_bg": "#69511e", + "color_shape_icon": "#cecece", + "color_empty_list_icon": "#d8b167", + "color_mobile_menu_bg": "#1a1515", + "color_mobile_menu_text": "#d5ccc4", + "color_mobile_menu_link": "#938f85", + "color_mobile_menu_link_active": "#f0e6dd", + "color_mobile_menu_icon": "#4f4644", + "color_mobile_menu_icon_active": "#f0e6dd", + "color_mobile_menu_navigation": "#e0c287", + "color_mobile_menu_control": "#ad863f", + "color_header_top_bg": "#010000", + "color_header_top_text": "#d5ccc4", + "color_header_top_link": "#968d8a", + "color_header_top_link_hover": "#938e8d", + "color_header_top_link_active": "#a3a09f", + "color_header_bottom_bg": "#010000", + "color_header_bottom_text": "#d5ccc4", + "color_header_bottom_link": "#ae8946", + "color_header_bottom_link_hover": "#938e8d", + "color_header_bottom_link_active": "#a3a09f", + "color_footer_top_bg": "#14161a", + "color_footer_top_text": "#d5ccc4", + "color_footer_top_link": "#9a8e76", + "color_footer_top_link_hover": "#8f8f8f", + "color_footer_top_link_active": "#8f8f8f", + "color_footer_bottom_bg": "#14161a", + "color_footer_bottom_text": "#d5ccc4", + "color_footer_bottom_link": "#908f8f", + "color_footer_bottom_link_hover": "#8f8f8f", + "color_footer_bottom_link_active": "#8f8f8f" +} diff --git a/client-app/assets/presets/coffee.dark.json b/client-app/assets/presets/coffee.dark.json new file mode 100644 index 0000000000..ba62fcb6c5 --- /dev/null +++ b/client-app/assets/presets/coffee.dark.json @@ -0,0 +1,126 @@ +{ + "color_primary_50": "#0e0c0a", + "color_primary_100": "#1d1815", + "color_primary_200": "#3a2c27", + "color_primary_300": "#5a433a", + "color_primary_400": "#78574a", + "color_primary_500": "#9f7665", + "color_primary_600": "#ad897c", + "color_primary_700": "#c4a79b", + "color_primary_800": "#d8c3bb", + "color_primary_900": "#eee2dd", + "color_primary_950": "#f8f0ee", + "color_secondary_50": "#0a0b0c", + "color_secondary_100": "#15181a", + "color_secondary_200": "#293033", + "color_secondary_300": "#3c484d", + "color_secondary_400": "#4f6068", + "color_secondary_500": "#6e8189", + "color_secondary_600": "#84939b", + "color_secondary_700": "#a2aeb5", + "color_secondary_800": "#c1cace", + "color_secondary_900": "#dfe4e7", + "color_secondary_950": "#edf0f2", + "color_accent_50": "#070b0d", + "color_accent_100": "#111a1e", + "color_accent_200": "#1f333e", + "color_accent_300": "#2a4a5a", + "color_accent_400": "#36647c", + "color_accent_500": "#5284a0", + "color_accent_600": "#749cb3", + "color_accent_700": "#96b6c7", + "color_accent_800": "#b5ccda", + "color_accent_900": "#d8e6ee", + "color_accent_950": "#ecf4f8", + "color_neutral_50": "#0c0908", + "color_neutral_100": "#191615", + "color_neutral_200": "#292523", + "color_neutral_300": "#64605d", + "color_neutral_400": "#55514f", + "color_neutral_500": "#575350", + "color_neutral_600": "#a7a29e", + "color_neutral_700": "#d9d3cf", + "color_neutral_800": "#f0eae6", + "color_neutral_900": "#faf4f0", + "color_neutral_950": "#fff9f5", + "color_warning_50": "#292417", + "color_warning_100": "#41371b", + "color_warning_200": "#735e19", + "color_warning_300": "#977300", + "color_warning_400": "#d09213", + "color_warning_500": "#af711e", + "color_warning_600": "#ffb55b", + "color_warning_700": "#ffcd9e", + "color_warning_800": "#ffe0c6", + "color_warning_900": "#ffeee1", + "color_warning_950": "#fff7ef", + "color_danger_50": "#130c0b", + "color_danger_100": "#2d1617", + "color_danger_200": "#663133", + "color_danger_300": "#8c3438", + "color_danger_400": "#ae3135", + "color_danger_500": "#dd4741", + "color_danger_600": "#ea5e51", + "color_danger_700": "#fb7e70", + "color_danger_800": "#ffa99e", + "color_danger_900": "#ffd5ce", + "color_danger_950": "#fff2f0", + "color_success_50": "#0e1511", + "color_success_100": "#051d09", + "color_success_200": "#1c3c2b", + "color_success_300": "#2c6245", + "color_success_400": "#2f7951", + "color_success_500": "#4c8a64", + "color_success_600": "#72a886", + "color_success_700": "#91bea0", + "color_success_800": "#b5d4bf", + "color_success_900": "#d1e4d8", + "color_success_950": "#e7f7ee", + "color_info_50": "#2c3337", + "color_info_100": "#2f3b40", + "color_info_200": "#324c59", + "color_info_300": "#335d71", + "color_info_400": "#316c88", + "color_info_500": "#4086ac", + "color_info_600": "#75a7c7", + "color_info_700": "#95bdd7", + "color_info_800": "#b4d3e9", + "color_info_900": "#d5eafb", + "color_info_950": "#ebf6ff", + "color_additional_50": "#141110", + "color_additional_950": "#f0e6dd", + "color_body_bg": "#060302", + "color_body_text": "#ebe3da", + "color_shape_icon_bg": "#2d5b72", + "color_shape_icon": "#cecece", + "color_price": "#d6b6a9", + "color_empty_list_icon": "#a17462", + "color_mobile_menu_bg": "#1d1310", + "color_mobile_menu_text": "#d5ccc4", + "color_mobile_menu_link": "#9d8b85", + "color_mobile_menu_link_active": "#f0e6dd", + "color_mobile_menu_icon": "#4f2e20", + "color_mobile_menu_icon_active": "#f0e6dd", + "color_mobile_menu_navigation": "#b08878", + "color_mobile_menu_control": "#a17462", + "color_header_top_bg": "#1d1310", + "color_header_top_text": "#d5ccc4", + "color_header_top_link": "#a3897e", + "color_header_top_link_hover": "#8b9092", + "color_header_top_link_active": "#889194", + "color_header_bottom_bg": "#010000", + "color_header_bottom_text": "#d5ccc4", + "color_header_bottom_link": "#ac978e", + "color_header_bottom_link_hover": "#82929a", + "color_header_bottom_link_active": "#969fa4", + "color_footer_top_bg": "#1d1310", + "color_footer_top_text": "#d5ccc4", + "color_footer_top_link": "#a3897e", + "color_footer_top_link_hover": "#8e8f91", + "color_footer_top_link_active": "#849298", + "color_footer_bottom_bg": "#1a1512", + "color_footer_bottom_text": "#d5ccc4", + "color_footer_bottom_link": "#889194", + "color_footer_bottom_link_hover": "#8b9092", + "color_footer_bottom_link_active": "#889194" +} diff --git a/client-app/assets/presets/default.dark.json b/client-app/assets/presets/default.dark.json new file mode 100644 index 0000000000..33eb60c1be --- /dev/null +++ b/client-app/assets/presets/default.dark.json @@ -0,0 +1,92 @@ +{ + "color_primary_50": "#17130b", + "color_primary_100": "#2b200f", + "color_primary_200": "#4b3511", + "color_primary_300": "#7f5405", + "color_primary_400": "#aa720e", + "color_primary_500": "#b0701e", + "color_primary_600": "#ffb977", + "color_primary_700": "#fec590", + "color_primary_800": "#ffd8b6", + "color_primary_900": "#ffe8d3", + "color_primary_950": "#fff7ef", + "color_secondary_50": "#010202", + "color_secondary_100": "#0c1013", + "color_secondary_200": "#232c32", + "color_secondary_300": "#3b4853", + "color_secondary_400": "#536474", + "color_secondary_500": "#698095", + "color_secondary_600": "#96a8bb", + "color_secondary_700": "#c0cfde", + "color_secondary_800": "#d6dee9", + "color_secondary_900": "#e9edf2", + "color_secondary_950": "#f2f7f7", + "color_accent_50": "#141e22", + "color_accent_100": "#18323a", + "color_accent_200": "#003a4a", + "color_accent_300": "#00495e", + "color_accent_400": "#005f7e", + "color_accent_500": "#3c87a8", + "color_accent_600": "#66a1bc", + "color_accent_700": "#86b6cd", + "color_accent_800": "#a5cedf", + "color_accent_900": "#c4eaf5", + "color_accent_950": "#e1f6ff", + "color_neutral_50": "#0c0908", + "color_neutral_100": "#191615", + "color_neutral_200": "#292523", + "color_neutral_300": "#64605d", + "color_neutral_400": "#55514f", + "color_neutral_500": "#575350", + "color_neutral_600": "#a7a29e", + "color_neutral_700": "#d9d3cf", + "color_neutral_800": "#f0eae6", + "color_neutral_900": "#faf4f0", + "color_neutral_950": "#fff9f5", + "color_warning_50": "#292417", + "color_warning_100": "#41371b", + "color_warning_200": "#735e19", + "color_warning_300": "#977300", + "color_warning_400": "#d09213", + "color_warning_500": "#af711e", + "color_warning_600": "#ffb55b", + "color_warning_700": "#ffcd9e", + "color_warning_800": "#ffe0c6", + "color_warning_900": "#ffeee1", + "color_warning_950": "#fff7ef", + "color_danger_50": "#130c0b", + "color_danger_100": "#2d1617", + "color_danger_200": "#663133", + "color_danger_300": "#8c3438", + "color_danger_400": "#ae3135", + "color_danger_500": "#dd4741", + "color_danger_600": "#ea5e51", + "color_danger_700": "#fb7e70", + "color_danger_800": "#ffa99e", + "color_danger_900": "#ffd5ce", + "color_danger_950": "#fff2f0", + "color_success_50": "#0e1511", + "color_success_100": "#051d09", + "color_success_200": "#1c3c2b", + "color_success_300": "#2c6245", + "color_success_400": "#2f7951", + "color_success_500": "#4c8a64", + "color_success_600": "#72a886", + "color_success_700": "#91bea0", + "color_success_800": "#b5d4bf", + "color_success_900": "#d1e4d8", + "color_success_950": "#e7f7ee", + "color_info_50": "#2c3337", + "color_info_100": "#2f3b40", + "color_info_200": "#324c59", + "color_info_300": "#335d71", + "color_info_400": "#316c88", + "color_info_500": "#4086ac", + "color_info_600": "#75a7c7", + "color_info_700": "#95bdd7", + "color_info_800": "#b4d3e9", + "color_info_900": "#d5eafb", + "color_info_950": "#ebf6ff", + "color_additional_50": "#141110", + "color_additional_950": "#f0e6dd" +} diff --git a/client-app/assets/presets/index.ts b/client-app/assets/presets/index.ts index 0f0936ed43..ad007804c3 100644 --- a/client-app/assets/presets/index.ts +++ b/client-app/assets/presets/index.ts @@ -1,8 +1,14 @@ +import blackGoldDark from './black-gold.dark.json' import blackGold from './black-gold.json' +import coffeeDark from './coffee.dark.json' import coffee from './coffee.json' +import defaultDark from './default.dark.json' import defaultPreset from './default.json' +import mercuryDark from './mercury.dark.json' import mercury from './mercury.json' +import purplePinkDark from './purple-pink.dark.json' import purplePink from './purple-pink.json' +import watermelonDark from './watermelon.dark.json' import watermelon from './watermelon.json' import type { IThemeConfigPreset } from "@/core/types"; @@ -14,3 +20,12 @@ export const presets: Record = { watermelon: watermelon, coffee: coffee, } as const; + +export const darkPresets: Record = { + ['black-gold']: blackGoldDark, + default: defaultDark, + mercury: mercuryDark, + ['purple-pink']: purplePinkDark, + watermelon: watermelonDark, + coffee: coffeeDark, +} as const; diff --git a/client-app/assets/presets/mercury.dark.json b/client-app/assets/presets/mercury.dark.json new file mode 100644 index 0000000000..33eb60c1be --- /dev/null +++ b/client-app/assets/presets/mercury.dark.json @@ -0,0 +1,92 @@ +{ + "color_primary_50": "#17130b", + "color_primary_100": "#2b200f", + "color_primary_200": "#4b3511", + "color_primary_300": "#7f5405", + "color_primary_400": "#aa720e", + "color_primary_500": "#b0701e", + "color_primary_600": "#ffb977", + "color_primary_700": "#fec590", + "color_primary_800": "#ffd8b6", + "color_primary_900": "#ffe8d3", + "color_primary_950": "#fff7ef", + "color_secondary_50": "#010202", + "color_secondary_100": "#0c1013", + "color_secondary_200": "#232c32", + "color_secondary_300": "#3b4853", + "color_secondary_400": "#536474", + "color_secondary_500": "#698095", + "color_secondary_600": "#96a8bb", + "color_secondary_700": "#c0cfde", + "color_secondary_800": "#d6dee9", + "color_secondary_900": "#e9edf2", + "color_secondary_950": "#f2f7f7", + "color_accent_50": "#141e22", + "color_accent_100": "#18323a", + "color_accent_200": "#003a4a", + "color_accent_300": "#00495e", + "color_accent_400": "#005f7e", + "color_accent_500": "#3c87a8", + "color_accent_600": "#66a1bc", + "color_accent_700": "#86b6cd", + "color_accent_800": "#a5cedf", + "color_accent_900": "#c4eaf5", + "color_accent_950": "#e1f6ff", + "color_neutral_50": "#0c0908", + "color_neutral_100": "#191615", + "color_neutral_200": "#292523", + "color_neutral_300": "#64605d", + "color_neutral_400": "#55514f", + "color_neutral_500": "#575350", + "color_neutral_600": "#a7a29e", + "color_neutral_700": "#d9d3cf", + "color_neutral_800": "#f0eae6", + "color_neutral_900": "#faf4f0", + "color_neutral_950": "#fff9f5", + "color_warning_50": "#292417", + "color_warning_100": "#41371b", + "color_warning_200": "#735e19", + "color_warning_300": "#977300", + "color_warning_400": "#d09213", + "color_warning_500": "#af711e", + "color_warning_600": "#ffb55b", + "color_warning_700": "#ffcd9e", + "color_warning_800": "#ffe0c6", + "color_warning_900": "#ffeee1", + "color_warning_950": "#fff7ef", + "color_danger_50": "#130c0b", + "color_danger_100": "#2d1617", + "color_danger_200": "#663133", + "color_danger_300": "#8c3438", + "color_danger_400": "#ae3135", + "color_danger_500": "#dd4741", + "color_danger_600": "#ea5e51", + "color_danger_700": "#fb7e70", + "color_danger_800": "#ffa99e", + "color_danger_900": "#ffd5ce", + "color_danger_950": "#fff2f0", + "color_success_50": "#0e1511", + "color_success_100": "#051d09", + "color_success_200": "#1c3c2b", + "color_success_300": "#2c6245", + "color_success_400": "#2f7951", + "color_success_500": "#4c8a64", + "color_success_600": "#72a886", + "color_success_700": "#91bea0", + "color_success_800": "#b5d4bf", + "color_success_900": "#d1e4d8", + "color_success_950": "#e7f7ee", + "color_info_50": "#2c3337", + "color_info_100": "#2f3b40", + "color_info_200": "#324c59", + "color_info_300": "#335d71", + "color_info_400": "#316c88", + "color_info_500": "#4086ac", + "color_info_600": "#75a7c7", + "color_info_700": "#95bdd7", + "color_info_800": "#b4d3e9", + "color_info_900": "#d5eafb", + "color_info_950": "#ebf6ff", + "color_additional_50": "#141110", + "color_additional_950": "#f0e6dd" +} diff --git a/client-app/assets/presets/purple-pink.dark.json b/client-app/assets/presets/purple-pink.dark.json new file mode 100644 index 0000000000..32d1046e53 --- /dev/null +++ b/client-app/assets/presets/purple-pink.dark.json @@ -0,0 +1,127 @@ +{ + "color_primary_50": "#130d10", + "color_primary_100": "#27171f", + "color_primary_200": "#50293c", + "color_primary_300": "#7c3958", + "color_primary_400": "#a94773", + "color_primary_500": "#cf4f84", + "color_primary_600": "#eb6495", + "color_primary_700": "#f78aae", + "color_primary_800": "#ffb2c9", + "color_primary_900": "#ffdbe7", + "color_primary_950": "#fff0f5", + "color_secondary_50": "#0d0c10", + "color_secondary_100": "#191520", + "color_secondary_200": "#31283f", + "color_secondary_300": "#493b63", + "color_secondary_400": "#614d89", + "color_secondary_500": "#886ec2", + "color_secondary_600": "#a38be6", + "color_secondary_700": "#c7b5ff", + "color_secondary_800": "#ddd2ff", + "color_secondary_900": "#eee8ff", + "color_secondary_950": "#f4f1ff", + "color_accent_50": "#10141a", + "color_accent_100": "#19212c", + "color_accent_200": "#2b3b52", + "color_accent_300": "#3b5479", + "color_accent_400": "#4b6da2", + "color_accent_500": "#507dc2", + "color_accent_600": "#689ef1", + "color_accent_700": "#89b6f9", + "color_accent_800": "#accdff", + "color_accent_900": "#d3e4ff", + "color_accent_950": "#e5efff", + "color_neutral_50": "#090705", + "color_neutral_100": "#171313", + "color_neutral_200": "#302c2a", + "color_neutral_300": "#626165", + "color_neutral_400": "#635f5d", + "color_neutral_500": "#4f4d51", + "color_neutral_600": "#afafb5", + "color_neutral_700": "#d4d2d7", + "color_neutral_800": "#e7e7ed", + "color_neutral_900": "#f5efeb", + "color_neutral_950": "#fdf7f3", + "color_warning_50": "#292417", + "color_warning_100": "#41371b", + "color_warning_200": "#735e19", + "color_warning_300": "#977300", + "color_warning_400": "#d09213", + "color_warning_500": "#af711e", + "color_warning_600": "#ffb55b", + "color_warning_700": "#ffcd9e", + "color_warning_800": "#ffe0c6", + "color_warning_900": "#ffeee1", + "color_warning_950": "#fff7ef", + "color_danger_50": "#130c0b", + "color_danger_100": "#2d1617", + "color_danger_200": "#663133", + "color_danger_300": "#8c3438", + "color_danger_400": "#ae3135", + "color_danger_500": "#dd4741", + "color_danger_600": "#ea5e51", + "color_danger_700": "#fb7e70", + "color_danger_800": "#ffa99e", + "color_danger_900": "#ffd5ce", + "color_danger_950": "#fff2f0", + "color_success_50": "#0e1511", + "color_success_100": "#051d09", + "color_success_200": "#1c3c2b", + "color_success_300": "#2c6245", + "color_success_400": "#2f7951", + "color_success_500": "#4c8a64", + "color_success_600": "#72a886", + "color_success_700": "#91bea0", + "color_success_800": "#b5d4bf", + "color_success_900": "#d1e4d8", + "color_success_950": "#e7f7ee", + "color_info_50": "#2c3337", + "color_info_100": "#2f3b40", + "color_info_200": "#324c59", + "color_info_300": "#335d71", + "color_info_400": "#316c88", + "color_info_500": "#4086ac", + "color_info_600": "#75a7c7", + "color_info_700": "#95bdd7", + "color_info_800": "#b4d3e9", + "color_info_900": "#d5eafb", + "color_info_950": "#ebf6ff", + "color_additional_50": "#141110", + "color_additional_950": "#f0e6dd", + "color_body_bg": "#060302", + "color_body_text": "#ebe3da", + "color_link": "#568fe5", + "color_link_hover": "#6390d5", + "color_shape_icon_bg": "#5b4d76", + "color_shape_icon": "#cecece", + "color_empty_list_icon": "#d84786", + "color_mobile_menu_bg": "#171322", + "color_mobile_menu_text": "#d5ccc4", + "color_mobile_menu_link": "#7d91ad", + "color_mobile_menu_link_active": "#f0e6dd", + "color_mobile_menu_icon": "#072c60", + "color_mobile_menu_icon_active": "#f0e6dd", + "color_mobile_menu_navigation": "#d84786", + "color_mobile_active_control": "#d84786", + "color_header_top_bg": "#010000", + "color_header_top_text": "#d1c6f1", + "color_header_top_link": "#568fe5", + "color_header_top_link_hover": "#d16594", + "color_header_top_link_active": "#e05790", + "color_header_bottom_bg": "#010000", + "color_header_bottom_text": "#cfcecb", + "color_header_bottom_link": "#a395cc", + "color_header_bottom_link_hover": "#beb6d7", + "color_header_bottom_link_active": "#beb6d7", + "color_footer_top_bg": "#190d31", + "color_footer_top_text": "#d5ccc4", + "color_footer_top_link": "#c17395", + "color_footer_top_link_hover": "#d16594", + "color_footer_top_link_active": "#e05790", + "color_footer_bottom_bg": "#18102a", + "color_footer_bottom_text": "#d5ccc4", + "color_footer_bottom_link": "#998c84", + "color_footer_bottom_link_hover": "#968d8a", + "color_footer_bottom_link_active": "#9e8b7f" +} diff --git a/client-app/assets/presets/watermelon.dark.json b/client-app/assets/presets/watermelon.dark.json new file mode 100644 index 0000000000..2aeaad9b9b --- /dev/null +++ b/client-app/assets/presets/watermelon.dark.json @@ -0,0 +1,127 @@ +{ + "color_primary_50": "#111207", + "color_primary_100": "#242513", + "color_primary_200": "#494c2b", + "color_primary_300": "#6e7243", + "color_primary_400": "#919958", + "color_primary_500": "#7c8436", + "color_primary_600": "#bcc57a", + "color_primary_700": "#c9d296", + "color_primary_800": "#d8dfb3", + "color_primary_900": "#e6ead2", + "color_primary_950": "#eef0e2", + "color_secondary_50": "#040303", + "color_secondary_100": "#141010", + "color_secondary_200": "#312929", + "color_secondary_300": "#504243", + "color_secondary_400": "#6f5c5c", + "color_secondary_500": "#907677", + "color_secondary_600": "#a79091", + "color_secondary_700": "#bfacac", + "color_secondary_800": "#d6c8c9", + "color_secondary_900": "#ebe5e5", + "color_secondary_950": "#f7f4f4", + "color_accent_50": "#2b2626", + "color_accent_100": "#403131", + "color_accent_200": "#6b4646", + "color_accent_300": "#985959", + "color_accent_400": "#bf6363", + "color_accent_500": "#ce5857", + "color_accent_600": "#ff7c71", + "color_accent_700": "#ffa194", + "color_accent_800": "#ffc3ba", + "color_accent_900": "#ffe4e0", + "color_accent_950": "#fff5f3", + "color_neutral_50": "#0c0a08", + "color_neutral_100": "#211f1c", + "color_neutral_200": "#292925", + "color_neutral_300": "#64625e", + "color_neutral_400": "#5d5b58", + "color_neutral_500": "#4d4b46", + "color_neutral_600": "#b7b3ab", + "color_neutral_700": "#e0ddd7", + "color_neutral_800": "#f1ede8", + "color_neutral_900": "#f7f5ef", + "color_neutral_950": "#fffaf7", + "color_warning_50": "#292417", + "color_warning_100": "#41371b", + "color_warning_200": "#735e19", + "color_warning_300": "#977300", + "color_warning_400": "#d09213", + "color_warning_500": "#af711e", + "color_warning_600": "#ffb55b", + "color_warning_700": "#ffcd9e", + "color_warning_800": "#ffe0c6", + "color_warning_900": "#ffeee1", + "color_warning_950": "#fff7ef", + "color_danger_50": "#130c0b", + "color_danger_100": "#2d1617", + "color_danger_200": "#663133", + "color_danger_300": "#8c3438", + "color_danger_400": "#ae3135", + "color_danger_500": "#dd4741", + "color_danger_600": "#ea5e51", + "color_danger_700": "#fb7e70", + "color_danger_800": "#ffa99e", + "color_danger_900": "#ffd5ce", + "color_danger_950": "#fff2f0", + "color_success_50": "#0e1511", + "color_success_100": "#051d09", + "color_success_200": "#1c3c2b", + "color_success_300": "#2c6245", + "color_success_400": "#2f7951", + "color_success_500": "#4c8a64", + "color_success_600": "#72a886", + "color_success_700": "#91bea0", + "color_success_800": "#b5d4bf", + "color_success_900": "#d1e4d8", + "color_success_950": "#e7f7ee", + "color_info_50": "#2c3337", + "color_info_100": "#2f3b40", + "color_info_200": "#324c59", + "color_info_300": "#335d71", + "color_info_400": "#316c88", + "color_info_500": "#4086ac", + "color_info_600": "#75a7c7", + "color_info_700": "#95bdd7", + "color_info_800": "#b4d3e9", + "color_info_900": "#d5eafb", + "color_info_950": "#ebf6ff", + "color_additional_50": "#141110", + "color_additional_950": "#f0e6dd", + "color_body_bg": "#050301", + "color_body_text": "#ebe3da", + "color_link": "#4b9abc", + "color_link_hover": "#5e98b2", + "color_shape_icon_bg": "#97242a", + "color_shape_icon": "#cecece", + "color_empty_list_icon": "#aeb85f", + "color_mobile_menu_bg": "#1a1515", + "color_mobile_menu_text": "#d5ccc4", + "color_mobile_menu_link": "#9d8a8a", + "color_mobile_menu_link_active": "#f0e6dd", + "color_mobile_menu_icon": "#4c000b", + "color_mobile_menu_icon_active": "#f0e6dd", + "color_mobile_menu_navigation": "#aeb85f", + "color_mobile_menu_control": "#aeb85f", + "color_header_top_bg": "#1a1415", + "color_header_top_text": "#d5ccc4", + "color_header_top_link": "#968c8c", + "color_header_top_link_hover": "#bf7979", + "color_header_top_link_active": "#9b8b8b", + "color_header_bottom_bg": "#010000", + "color_header_bottom_text": "#cfcecb", + "color_header_bottom_link": "#918f89", + "color_header_bottom_link_hover": "#b2b1ad", + "color_header_bottom_link_active": "#b2b1ad", + "color_footer_top_bg": "#1a1415", + "color_footer_top_text": "#d5ccc4", + "color_footer_top_link": "#918f8c", + "color_footer_top_link_hover": "#8f8f8f", + "color_footer_top_link_active": "#8f8f8f", + "color_footer_bottom_bg": "#1a1515", + "color_footer_bottom_text": "#d5ccc4", + "color_footer_bottom_link": "#968c8c", + "color_footer_bottom_link_hover": "#8f8f8f", + "color_footer_bottom_link_active": "#8f8f8f" +} diff --git a/client-app/assets/styles/_custom.scss b/client-app/assets/styles/_custom.scss index e1657bde4d..fe06dcc4b9 100644 --- a/client-app/assets/styles/_custom.scss +++ b/client-app/assets/styles/_custom.scss @@ -1,7 +1,96 @@ -/* your custom styles here - * - * customization example: - * .vc-typography--variant--h1 { - * @apply normal-case; - * } - * */ +/* Dark mode overrides */ +html.dark { + color-scheme: dark; + + /* Keep header/footer dark in dark mode. + In the dark preset, shades are mirrored: low numbers (50-200) hold dark values, + high numbers (800-950) hold light values. We must use the inverted shade numbers + so backgrounds stay dark and text stays light. */ + --header-top-bg-color: var(--color-header-top-bg, var(--color-secondary-200)); + --header-top-text-color: var(--color-header-top-text, var(--color-neutral-900)); + --header-top-link-color: var(--color-header-top-link, var(--color-accent-600)); + --header-top-link-hover-color: var(--color-header-top-link-hover, var(--color-neutral-950)); + --header-top-link-active-color: var(--color-header-top-link-active, var(--color-accent-600)); + + --footer-top-bg-color: var(--color-footer-top-bg, var(--color-secondary-200)); + --footer-top-text-color: var(--color-footer-top-text, var(--color-neutral-900)); + --footer-top-link-color: var(--color-footer-top-link, var(--color-neutral-600)); + --footer-top-link-hover-color: var(--color-footer-top-link-hover, var(--color-neutral-950)); + --footer-top-link-active-color: var(--color-footer-top-link-active, var(--color-neutral-950)); + + --footer-bottom-bg-color: var(--color-footer-bottom-bg, var(--color-secondary-100)); + --footer-bottom-text-color: var(--color-footer-bottom-text, var(--color-neutral-900)); + --footer-bottom-link-color: var(--color-footer-bottom-link, var(--color-accent-600)); + --footer-bottom-link-hover-color: var(--color-footer-bottom-link-hover, var(--color-neutral-950)); + --footer-bottom-link-active-color: var(--color-footer-bottom-link-active, var(--color-neutral-950)); + + /* Mobile menu — keep dark */ + --mobile-menu-bg-color: var(--color-mobile-menu-bg, var(--color-secondary-200)); + --mobile-menu-text-color: var(--color-mobile-menu-text, var(--color-neutral-900)); + --mobile-menu-link-color: var(--color-mobile-menu-link, var(--color-neutral-800)); + --mobile-search-bar-bg: var(--color-mobile-search-bar-bg, var(--color-neutral-200)); + + /* Footer section headings: text-additional-50 becomes invisible on dark bg */ + .footer-links__title { + color: var(--color-neutral-950); + } + + /* Input placeholder text: ensure readability on dark surfaces */ + input::placeholder, + textarea::placeholder { + color: var(--color-neutral-600); + } + + /* Solid buttons: in dark mode additional-50 is near-black, so text must + use the light end (additional-950 = cream). */ + .vc-button--solid--primary, + .vc-button--solid--secondary, + .vc-button--solid--accent, + .vc-button--solid--success, + .vc-button--solid--danger, + .vc-button--solid--info { + --text-color: var(--color-additional-950); + } + + /* Per-color solid overrides: use -400 (dark in inverted scale) for WCAG AA + contrast with cream text. Hover lightens to -500 (standard dark-mode pattern). */ + @each $color in primary, secondary, accent, success, danger, info { + .vc-button--solid--#{$color} { + --bg-color: var(--color-#{$color}-400); + --border-color: var(--color-#{$color}-400); + + &:hover:not([class*="--loading"], [class*="--disabled"]) { + --bg-color: var(--color-#{$color}-500); + --border-color: var(--color-#{$color}-500); + } + } + } + + /* Outline buttons: bg is neutral-100 (dark in inverted scale). + Text at -500 is 4.43:1, bump to -600 for WCAG AA. */ + @each $color in primary, secondary, accent, success, danger, info { + .vc-button--outline--#{$color} { + --bg-color: var(--color-neutral-100); + --text-color: var(--color-#{$color}-600); + --border-color: var(--color-#{$color}-600); + } + } + + /* No-border buttons: default -500 text falls just below 4.5:1 on dark bg. + Bump to -600 (lighter in inverted scale) for ~5.9:1 WCAG AA compliance. */ + @each $color in primary, secondary, accent { + .vc-button--no-border--#{$color} { + --bg-color: transparent; + --border-color: transparent; + --text-color: var(--color-#{$color}-600); + } + } + + /* No-background buttons: text at -500 lacks contrast on dark surfaces. + Bump to -600 for WCAG AA. Neutral uses -600 for icon-only buttons too. */ + @each $color in primary, secondary, accent, neutral { + .vc-button--no-background--#{$color} { + --text-color: var(--color-#{$color}-600); + } + } +} diff --git a/client-app/core/composables/index.ts b/client-app/core/composables/index.ts index 0a713a15f5..c57fd4a4d8 100644 --- a/client-app/core/composables/index.ts +++ b/client-app/core/composables/index.ts @@ -5,6 +5,7 @@ export * from "./useBrowserTarget"; export * from "./useCategoriesRoutes"; export * from "./useCountries"; export * from "./useCurrency"; +export * from "./useDarkMode"; export * from "./useErrorsTranslator"; export * from "./useHistoricalEvents"; export * from "./useImpersonate"; diff --git a/client-app/core/composables/useDarkMode.ts b/client-app/core/composables/useDarkMode.ts new file mode 100644 index 0000000000..31c1f1a2aa --- /dev/null +++ b/client-app/core/composables/useDarkMode.ts @@ -0,0 +1,45 @@ +import { createGlobalState, useLocalStorage, useMediaQuery } from "@vueuse/core"; +import { computed, watch } from "vue"; + +type ColorModeType = "light" | "dark" | "system"; + +function _useDarkMode() { + const storedMode = useLocalStorage("vc-color-mode", "system"); + const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)"); + + const isDark = computed(() => { + if (storedMode.value === "system") { + return systemPrefersDark.value; + } + return storedMode.value === "dark"; + }); + + watch( + isDark, + (value) => { + document.documentElement.classList.toggle("dark", value); + }, + { immediate: true }, + ); + + function setMode(mode: ColorModeType) { + storedMode.value = mode; + } + + function toggle() { + if (storedMode.value === "system") { + storedMode.value = systemPrefersDark.value ? "light" : "dark"; + } else { + storedMode.value = storedMode.value === "dark" ? "light" : "dark"; + } + } + + return { + isDark, + mode: storedMode, + setMode, + toggle, + }; +} + +export const useDarkMode = createGlobalState(_useDarkMode); diff --git a/client-app/core/composables/useThemeContext.ts b/client-app/core/composables/useThemeContext.ts index fbc6028a44..0e41a53f58 100644 --- a/client-app/core/composables/useThemeContext.ts +++ b/client-app/core/composables/useThemeContext.ts @@ -31,15 +31,18 @@ function _useThemeContext() { throw new Error("The global state should be defined"); } - let preset = getPreset(presetNameToFileName(presetName)); + let resolvedName = presetNameToFileName(presetName); + let preset = getPreset(resolvedName); if (!preset) { const defaultPresetName = getThemeConfig().current; - preset = getPreset(presetNameToFileName(defaultPresetName)); + resolvedName = presetNameToFileName(defaultPresetName); + preset = getPreset(resolvedName); } if (preset) { themeContext.value.preset = preset; + themeContext.value.activePresetName = resolvedName; } else { throw new Error("Missing preset"); } diff --git a/client-app/core/plugins/config.plugin.ts b/client-app/core/plugins/config.plugin.ts index eb067c06a0..9d4574b717 100644 --- a/client-app/core/plugins/config.plugin.ts +++ b/client-app/core/plugins/config.plugin.ts @@ -1,22 +1,38 @@ +import { darkPresets } from "@/assets/presets"; import { configInjectionKey } from "../injection-keys"; -import type { IThemeContext } from "../types"; +import type { IThemeConfigPreset, IThemeContext } from "../types"; import type { App, Plugin } from "vue"; +function presetToCssVars(preset: IThemeConfigPreset): string { + return Object.entries(preset) + .map(([key, value]) => `--${key.replace(/_/g, "-")}: ${value};`) + .join(""); +} + +function presetNameToFileName(name: string): string { + return name.toLowerCase().replace(" ", "-"); +} + export const configPlugin: Plugin = { install: (app: App, options: IThemeContext) => { app.config.globalProperties.$cfg = options.settings; app.provide(configInjectionKey, options.settings); if (options.preset) { - // Set CSS variables to use as TailwindCSS arbitrary values: https://tailwindcss.com/docs/adding-custom-styles#using-arbitrary-values const styleElement = document.createElement("style"); - styleElement.innerText = ":root {"; + styleElement.id = "vc-theme-variables"; + + // Light mode variables (default) + let css = `:root { ${presetToCssVars(options.preset)} }`; - Object.entries(options.preset).forEach(([key, value]) => { - styleElement.innerText += `--${key.replace(/_/g, "-")}: ${value};`; - }); + // Dark mode variables + const presetName = presetNameToFileName(options.activePresetName || options.defaultPresetName || "default"); + const darkPreset = darkPresets[presetName]; + if (darkPreset) { + css += ` html.dark { ${presetToCssVars(darkPreset)} }`; + } - styleElement.innerText += "}"; + styleElement.innerText = css; document.head.prepend(styleElement); } }, diff --git a/client-app/core/types/theme-context.ts b/client-app/core/types/theme-context.ts index 2081aed17c..3a8ebee32f 100644 --- a/client-app/core/types/theme-context.ts +++ b/client-app/core/types/theme-context.ts @@ -14,5 +14,6 @@ export interface IThemeContext { settings: IThemeConfigSettings; preset?: IThemeConfigPreset; defaultPresetName: string; + activePresetName?: string; storeSettings: NonNullable["settings"]; } diff --git a/client-app/modules/back-in-stock/pages/subscriptions.vue b/client-app/modules/back-in-stock/pages/subscriptions.vue index 53f648c12d..8cd6ea3eb6 100644 --- a/client-app/modules/back-in-stock/pages/subscriptions.vue +++ b/client-app/modules/back-in-stock/pages/subscriptions.vue @@ -194,7 +194,7 @@ function openDeleteProductModal(ids: string[]): void { if (hasPagination && isLastPageWithOneItem) { pagination.value.page -= 1; } - // eslint-disable-next-line sonarjs/void-use + void broadcast.emit(dataChangedEvent); void fetchProductsAndSubscriptions(); }, diff --git a/client-app/modules/push-messages/index.ts b/client-app/modules/push-messages/index.ts index 5a984309d7..0e47dde32a 100644 --- a/client-app/modules/push-messages/index.ts +++ b/client-app/modules/push-messages/index.ts @@ -137,9 +137,8 @@ export async function init(router: Router, i18n: I18n) { }; router.addRoute(route); // NOTE: This route must be added before any asynchronous calls. Delaying it can cause a 404 error if accessed prematurely. - const { useWebPushNotificationsModule } = await import( - "./composables/useWebPushNotifications/useWebPushNotificationsModule" - ); + const { useWebPushNotificationsModule } = + await import("./composables/useWebPushNotifications/useWebPushNotificationsModule"); const { initModule } = useWebPushNotificationsModule(); await initModule(); } else { diff --git a/client-app/shared/layout/components/header/_internal/dark-mode-toggle.vue b/client-app/shared/layout/components/header/_internal/dark-mode-toggle.vue new file mode 100644 index 0000000000..5e75c55794 --- /dev/null +++ b/client-app/shared/layout/components/header/_internal/dark-mode-toggle.vue @@ -0,0 +1,20 @@ + + + diff --git a/client-app/shared/layout/components/header/_internal/top-header.vue b/client-app/shared/layout/components/header/_internal/top-header.vue index befdfdce90..9c46df9fb6 100644 --- a/client-app/shared/layout/components/header/_internal/top-header.vue +++ b/client-app/shared/layout/components/header/_internal/top-header.vue @@ -18,6 +18,10 @@
+ + + +
@@ -170,6 +174,7 @@ import { ROUTES } from "@/router/routes/constants"; import { useSignMeOut, useUser } from "@/shared/account"; import { CurrencySelector, LanguageSelector } from "@/shared/layout/components"; import { ShipToSelector } from "@/shared/ship-to-location"; +import DarkModeToggle from "./dark-mode-toggle.vue"; import TopHeaderLink from "./top-header-link.vue"; import TopHeaderOrganizations from "./top-header-organizations.vue"; diff --git a/client-app/ui-kit/composables/useSmartSticky.ts b/client-app/ui-kit/composables/useSmartSticky.ts index d3332f0958..84f1a969b8 100644 --- a/client-app/ui-kit/composables/useSmartSticky.ts +++ b/client-app/ui-kit/composables/useSmartSticky.ts @@ -459,7 +459,6 @@ export function useSmartSticky(options: ISmartStickyOptions): IUseSmartStickyRet watch([elementBounding.height, containerBounding.height], updateImmediate); watch(isEnabled, (value) => { - // eslint-disable-next-line sonarjs/no-selector-parameter if (value) { void update(); } else { diff --git a/index.html b/index.html index 7039de0351..93717d3bc7 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,17 @@ + + + diff --git a/locales/de.json b/locales/de.json index ab2f9df27c..b3994c606a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -315,7 +315,11 @@ "call_us_label": "Rufen Sie uns an: ", "logged_in_as": "eingeloggt als", "account_menu_label": "Kontomenü", - "no_results": "Keine Ergebnisse gefunden" + "no_results": "Keine Ergebnisse gefunden", + "dark_mode": { + "switch_to_light": "Zum hellen Modus wechseln", + "switch_to_dark": "Zum dunklen Modus wechseln" + } }, "bottom_header": { "catalog_menu_button": "Katalog", diff --git a/locales/en.json b/locales/en.json index b1782e5111..b4f480dfa6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -315,7 +315,11 @@ "call_us_label": "Call us: ", "logged_in_as": "logged in as", "account_menu_label": "Account menu", - "no_results": "No results found" + "no_results": "No results found", + "dark_mode": { + "switch_to_light": "Switch to light mode", + "switch_to_dark": "Switch to dark mode" + } }, "bottom_header": { "catalog_menu_button": "Catalog", diff --git a/locales/es.json b/locales/es.json index afe4b6ab4f..52fac0ae63 100644 --- a/locales/es.json +++ b/locales/es.json @@ -315,7 +315,11 @@ "call_us_label": "Llámanos: ", "logged_in_as": "conectado como", "account_menu_label": "Menú de cuenta", - "no_results": "No se encontraron resultados" + "no_results": "No se encontraron resultados", + "dark_mode": { + "switch_to_light": "Cambiar a modo claro", + "switch_to_dark": "Cambiar a modo oscuro" + } }, "bottom_header": { "catalog_menu_button": "Catálogo", diff --git a/locales/fi.json b/locales/fi.json index a64162aa32..f3e4385bdd 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -315,7 +315,11 @@ "call_us_label": "Soita meille: ", "logged_in_as": "kirjautuneena nimellä", "account_menu_label": "Tilivalikko", - "no_results": "Ei tuloksia" + "no_results": "Ei tuloksia", + "dark_mode": { + "switch_to_light": "Vaihda vaaleaan tilaan", + "switch_to_dark": "Vaihda tummaan tilaan" + } }, "bottom_header": { "catalog_menu_button": "Luettelo", diff --git a/locales/fr.json b/locales/fr.json index 2cc4d9ea16..1434848997 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -315,7 +315,11 @@ "call_us_label": "Appelez-nous : ", "logged_in_as": "connecté en tant que", "account_menu_label": "Menu du compte", - "no_results": "Aucun résultat trouvé" + "no_results": "Aucun résultat trouvé", + "dark_mode": { + "switch_to_light": "Passer en mode clair", + "switch_to_dark": "Passer en mode sombre" + } }, "bottom_header": { "catalog_menu_button": "Catalogue", diff --git a/locales/it.json b/locales/it.json index cb77286347..25986de9c4 100644 --- a/locales/it.json +++ b/locales/it.json @@ -315,7 +315,11 @@ "call_us_label": "Chiamaci: ", "logged_in_as": "connesso come", "account_menu_label": "Menu account", - "no_results": "Nessun risultato trovato" + "no_results": "Nessun risultato trovato", + "dark_mode": { + "switch_to_light": "Passa alla modalità chiara", + "switch_to_dark": "Passa alla modalità scura" + } }, "bottom_header": { "catalog_menu_button": "Catalogo", diff --git a/locales/ja.json b/locales/ja.json index 0972a61a28..2b641f7590 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -315,7 +315,11 @@ "call_us_label": "お電話: ", "logged_in_as": "ログインユーザー:", "account_menu_label": "アカウントメニュー", - "no_results": "結果が見つかりません" + "no_results": "結果が見つかりません", + "dark_mode": { + "switch_to_light": "ライトモードに切り替え", + "switch_to_dark": "ダークモードに切り替え" + } }, "bottom_header": { "catalog_menu_button": "カタログ", diff --git a/locales/no.json b/locales/no.json index 27c091f0fd..5f681c7ab4 100644 --- a/locales/no.json +++ b/locales/no.json @@ -315,7 +315,11 @@ "call_us_label": "Ring oss: ", "logged_in_as": "logget inn som", "account_menu_label": "Kontomeny", - "no_results": "Ingen resultater funnet" + "no_results": "Ingen resultater funnet", + "dark_mode": { + "switch_to_light": "Bytt til lyst modus", + "switch_to_dark": "Bytt til mørkt modus" + } }, "bottom_header": { "catalog_menu_button": "Katalog", diff --git a/locales/pl.json b/locales/pl.json index 3fc17b4e8d..142767acb6 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -315,7 +315,11 @@ "call_us_label": "Zadzwoń do nas: ", "logged_in_as": "zalogowany jako", "account_menu_label": "Menu konta", - "no_results": "Nie znaleziono wyników" + "no_results": "Nie znaleziono wyników", + "dark_mode": { + "switch_to_light": "Przełącz na jasny motyw", + "switch_to_dark": "Przełącz na ciemny motyw" + } }, "bottom_header": { "catalog_menu_button": "Katalog", diff --git a/locales/pt.json b/locales/pt.json index 1277bb90ff..756f0dea91 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -315,7 +315,11 @@ "call_us_label": "Ligue para nós: ", "logged_in_as": "logado como", "account_menu_label": "Menu da conta", - "no_results": "Nenhum resultado encontrado" + "no_results": "Nenhum resultado encontrado", + "dark_mode": { + "switch_to_light": "Mudar para modo claro", + "switch_to_dark": "Mudar para modo escuro" + } }, "bottom_header": { "catalog_menu_button": "Catálogo", diff --git a/locales/ru.json b/locales/ru.json index e6a15afe74..fa1f0fabd8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -315,7 +315,11 @@ "call_us_label": "Поддержка: ", "logged_in_as": "вход под именем", "account_menu_label": "Меню аккаунта", - "no_results": "Результаты не найдены" + "no_results": "Результаты не найдены", + "dark_mode": { + "switch_to_light": "Светлая тема", + "switch_to_dark": "Тёмная тема" + } }, "bottom_header": { "catalog_menu_button": "Каталог", diff --git a/locales/sv.json b/locales/sv.json index dd5d6957fd..b2dbe6bd38 100644 --- a/locales/sv.json +++ b/locales/sv.json @@ -315,7 +315,11 @@ "call_us_label": "Ring oss: ", "logged_in_as": "inloggad som", "account_menu_label": "Kontomeny", - "no_results": "Inga resultat hittades" + "no_results": "Inga resultat hittades", + "dark_mode": { + "switch_to_light": "Växla till ljust läge", + "switch_to_dark": "Växla till mörkt läge" + } }, "bottom_header": { "catalog_menu_button": "Katalog", diff --git a/locales/zh.json b/locales/zh.json index 5cd881cc2e..bb8bcadd6d 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -315,7 +315,11 @@ "call_us_label": "致电我们:", "logged_in_as": "已登录为", "account_menu_label": "帐户菜单", - "no_results": "未找到结果" + "no_results": "未找到结果", + "dark_mode": { + "switch_to_light": "切换到浅色模式", + "switch_to_dark": "切换到深色模式" + } }, "bottom_header": { "catalog_menu_button": "目录", diff --git a/package.json b/package.json index b6bab485dd..b6cb22024d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:typing": "yarn precheck && vitest --typecheck", "test:coverage": "yarn precheck && vitest --coverage", "generate:certificates": "yarn precheck && vite-node --mode production scripts/generate-certificates.ts", + "generate:dark-presets": "yarn precheck && vite-node scripts/generate-dark-presets.ts", "generate:graphql-types": "yarn precheck && vite-node scripts/graphql-codegen/generator.ts", "generate:backend-packages": "yarn precheck && vite-node scripts/generate-backend-packages-json.js", "generate:bundle-map": "yarn precheck && GENERATE_BUNDLE_MAP=true yarn build-only", @@ -105,6 +106,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/vue": "^8.1.0", "@tsconfig/node22": "^22.0.5", + "@types/culori": "^4", "@types/dompurify": "^3.2.0", "@types/google.maps": "^3.58.1", "@types/gtag.js": "^0.0.20", @@ -119,6 +121,7 @@ "autoprefixer": "^10.4.23", "browserslist": "^4.28.1", "browserslist-to-esbuild": "^2.1.1", + "culori": "^4.0.2", "dependency-cruiser": "^17.3.7", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", diff --git a/scripts/generate-dark-presets.ts b/scripts/generate-dark-presets.ts new file mode 100644 index 0000000000..c3f34b928b --- /dev/null +++ b/scripts/generate-dark-presets.ts @@ -0,0 +1,502 @@ +/** + * Dark Preset Generator + * + * Reads each light preset JSON and generates a dark variant using OKLCH lightness inversion. + * Validates WCAG AA contrast ratios (4.5:1 minimum for normal text). + * + * Usage: yarn generate:dark-presets + */ + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { resolve, join, basename, relative } from "node:path"; +import { parse, formatHex, wcagContrast, converter, clampChroma, displayable } from "culori"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface IOklch { + mode: "oklch"; + l: number; + c: number; + h?: number; + alpha?: number; +} + +type PresetType = Record; + +interface IContrastAdjustment { + pair: string; + original: number; + adjusted: number; + foregroundKey: string; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PRESETS_DIR = resolve(import.meta.dirname, "../client-app/assets/presets"); + +const PALETTE_FAMILIES = ["primary", "secondary", "accent", "neutral", "warning", "danger", "success", "info"] as const; +const SHADE_LEVELS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const; + +/** Mirrored shade pairs for lightness inversion. */ +const SHADE_MIRROR_PAIRS: ReadonlyArray<[number, number]> = [ + [50, 950], + [100, 900], + [200, 800], + [300, 700], + [400, 600], +]; + +const WCAG_AA_RATIO = 4.5; + +const toOklch = converter("oklch"); + +// --------------------------------------------------------------------------- +// Color helpers +// --------------------------------------------------------------------------- + +function hexToOklch(hex: string): IOklch { + const parsed = parse(hex); + if (!parsed) { + throw new Error(`Failed to parse color: ${hex}`); + } + const oklch = toOklch(parsed) as IOklch; + return { + mode: "oklch", + l: oklch.l ?? 0, + c: oklch.c ?? 0, + h: oklch.h, + alpha: oklch.alpha, + }; +} + +function oklchToHex(color: IOklch): string { + // Gamut-map to sRGB so formatHex never produces out-of-range values + const clamped = displayable(color) ? color : clampChroma(color, "oklch"); + return formatHex(clamped); +} + +function clampValue(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function invertLightness(lightness: number): number { + return 1 - lightness; +} + +/** + * Adjust a foreground color's lightness until it achieves at least `targetRatio` + * contrast against the given background hex. Returns the adjusted hex. + */ +function ensureContrast(fgHex: string, bgHex: string, targetRatio: number): { hex: string; adjusted: boolean } { + const ratio = wcagContrast(fgHex, bgHex); + if (ratio >= targetRatio) { + return { hex: fgHex, adjusted: false }; + } + + const fgOklch = hexToOklch(fgHex); + const bgOklch = hexToOklch(bgHex); + + // Determine direction: if bg is dark, push fg lighter; if bg is light, push fg darker + const direction = bgOklch.l < 0.5 ? 1 : -1; + const step = 0.01; + let bestHex = fgHex; + let currentL = fgOklch.l; + + for (let i = 0; i < 100; i++) { + currentL += direction * step; + currentL = clampValue(currentL, 0, 1); + + const candidate: IOklch = { ...fgOklch, l: currentL }; + const candidateHex = oklchToHex(candidate); + const candidateRatio = wcagContrast(candidateHex, bgHex); + + if (candidateRatio >= targetRatio) { + bestHex = candidateHex; + break; + } + + // If we hit the boundary without success, use the best we have + if (currentL <= 0 || currentL >= 1) { + bestHex = candidateHex; + break; + } + } + + return { hex: bestHex, adjusted: true }; +} + +// --------------------------------------------------------------------------- +// Palette shade inversion +// --------------------------------------------------------------------------- + +function isPaletteKey(key: string): boolean { + return PALETTE_FAMILIES.some((family) => { + const regex = new RegExp(`^color_${family}_\\d+$`); + return regex.test(key); + }); +} + +/** Inject minimum chroma for neutral family to prevent pure grey. */ +function applyNeutralWarmth(color: IOklch, minChroma: number): void { + color.c = Math.max(color.c, minChroma); + color.h = color.h ?? 55; +} + +/** Collect parsed OKLCH shades for a single palette family. */ +function collectFamilyShades(lightPreset: PresetType, family: string): Record { + const shades: Record = {}; + for (const shade of SHADE_LEVELS) { + const key = `color_${family}_${shade}`; + const hex = lightPreset[key]; + if (hex) { + shades[shade] = hexToOklch(hex); + } + } + return shades; +} + +/** Invert a mirrored pair of shades, swapping their lightness values. */ +function invertMirrorPair( + family: string, + lowShade: number, + highShade: number, + shades: Record, + result: PresetType, +): void { + const lowColor = shades[lowShade]; + const highColor = shades[highShade]; + + if (!lowColor || !highColor) { + return; + } + + const darkLow: IOklch = { mode: "oklch", l: highColor.l, c: lowColor.c * 0.9, h: lowColor.h }; + const darkHigh: IOklch = { mode: "oklch", l: lowColor.l, c: highColor.c * 0.9, h: highColor.h }; + + if (family === "neutral") { + applyNeutralWarmth(darkLow, 0.006); + applyNeutralWarmth(darkHigh, 0.008); + } + + result[`color_${family}_${lowShade}`] = oklchToHex(darkLow); + result[`color_${family}_${highShade}`] = oklchToHex(darkHigh); +} + +/** Invert the 500 (mid) shade with optional clamping for semantic families. */ +function invertMidShade(family: string, shades: Record, result: PresetType): void { + const mid = shades[500]; + if (!mid) { + return; + } + + const invertedL = invertLightness(mid.l); + const darkMid: IOklch = { + mode: "oklch", + l: family === "neutral" ? invertedL : clampValue(invertedL, 0.55, 0.85), + c: mid.c * 0.9, + h: mid.h, + }; + + if (family === "neutral") { + applyNeutralWarmth(darkMid, 0.007); + } + + result[`color_${family}_500`] = oklchToHex(darkMid); +} + +function invertPaletteShades(lightPreset: PresetType): PresetType { + const result: PresetType = {}; + + for (const family of PALETTE_FAMILIES) { + const shades = collectFamilyShades(lightPreset, family); + + for (const [lowShade, highShade] of SHADE_MIRROR_PAIRS) { + invertMirrorPair(family, lowShade, highShade, shades, result); + } + + invertMidShade(family, shades, result); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Semantic color inversion +// --------------------------------------------------------------------------- + +/** Hardcoded color mappings for specific keys. */ +const HARDCODED_COLORS: Record = { + color_additional_50: "#141110", + color_additional_950: "#f0e6dd", + color_mobile_menu_link_active: "#f0e6dd", + color_mobile_menu_icon_active: "#f0e6dd", +}; + +/** Try to handle a specific named key with custom inversion logic. Returns null if not handled. */ +function invertNamedSemanticKey(key: string, oklch: IOklch): string | null { + if (key === "color_body_bg") { + return oklchToHex({ mode: "oklch", l: 0.1, c: 0.012, h: oklch.h ?? 50 }); + } + if (key === "color_body_text") { + return oklchToHex({ mode: "oklch", l: 0.92, c: 0.015, h: oklch.h ?? 70 }); + } + if (key === "color_price") { + return oklchToHex({ ...oklch, l: Math.max(oklch.l, 0.8) }); + } + if (key === "color_empty_list_icon") { + return oklchToHex({ ...oklch, l: Math.max(oklch.l, 0.6) }); + } + if ( + key === "color_mobile_menu_navigation" || + key === "color_mobile_menu_control" || + key === "color_mobile_active_control" + ) { + return oklchToHex({ ...oklch, l: Math.max(oklch.l, 0.6) }); + } + if (key === "color_shape_icon_bg") { + return oklchToHex({ ...oklch, l: clampValue(invertLightness(oklch.l), 0.45, 0.65), c: oklch.c * 0.9 }); + } + if (key === "color_shape_icon") { + return oklchToHex({ ...oklch, l: clampValue(invertLightness(oklch.l), 0.85, 0.98) }); + } + return null; +} + +/** Invert a semantic color based on its suffix (_bg, _text, _link). Returns null if not handled. */ +function invertBySuffix(key: string, oklch: IOklch): string | null { + if (key.endsWith("_bg")) { + return oklchToHex({ + ...oklch, + l: clampValue(invertLightness(oklch.l), 0.05, 0.2), + c: Math.max(oklch.c * 0.6, 0.008), + h: oklch.h, + }); + } + if (key.endsWith("_text")) { + const targetC = oklch.c < 0.005 ? 0.015 : oklch.c * 0.7; + const targetH = oklch.c < 0.005 ? 65 : oklch.h; + return oklchToHex({ mode: "oklch", l: clampValue(invertLightness(oklch.l), 0.85, 0.98), c: targetC, h: targetH }); + } + if (key.endsWith("_link") || key.endsWith("_link_hover") || key.endsWith("_link_active")) { + return oklchToHex({ ...oklch, l: clampValue(invertLightness(oklch.l), 0.65, 0.85), c: oklch.c * 0.95 }); + } + return null; +} + +function invertSemanticColor(key: string, hex: string): string { + const hardcoded = HARDCODED_COLORS[key]; + if (hardcoded) { + return hardcoded; + } + + const oklch = hexToOklch(hex); + + const namedResult = invertNamedSemanticKey(key, oklch); + if (namedResult) { + return namedResult; + } + + const suffixResult = invertBySuffix(key, oklch); + if (suffixResult) { + return suffixResult; + } + + // Any other semantic color: just invert lightness + return oklchToHex({ ...oklch, l: invertLightness(oklch.l) }); +} + +// --------------------------------------------------------------------------- +// Contrast validation and fixing +// --------------------------------------------------------------------------- + +/** Check a single fg/bg pair and auto-adjust fg lightness if contrast is below `ratio`. */ +function checkAndFixContrast( + darkPreset: PresetType, + adjustments: IContrastAdjustment[], + fgKey: string, + bgKey: string, + pairLabel: string, + ratio: number = WCAG_AA_RATIO, +): void { + const fgHex = darkPreset[fgKey]; + const bgHex = darkPreset[bgKey]; + + if (!fgHex || !bgHex) { + return; + } + + const { hex, adjusted } = ensureContrast(fgHex, bgHex, ratio); + if (adjusted) { + const originalRatio = wcagContrast(fgHex, bgHex); + const newRatio = wcagContrast(hex, bgHex); + darkPreset[fgKey] = hex; + adjustments.push({ pair: pairLabel, original: originalRatio, adjusted: newRatio, foregroundKey: fgKey }); + } +} + +/** Validate palette 500 shades against dark surfaces. */ +function validatePaletteContrast(darkPreset: PresetType, adjustments: IContrastAdjustment[]): void { + const check = (fg: string, bg: string, label: string) => checkAndFixContrast(darkPreset, adjustments, fg, bg, label); + + // 500 shades on neutral_950 + for (const family of PALETTE_FAMILIES) { + check(`color_${family}_500`, "color_neutral_950", `${family}_500 on neutral_950`); + } + + // 500 shades on additional_50 (button text contrast) + if (darkPreset["color_additional_50"]) { + for (const family of PALETTE_FAMILIES) { + if (family !== "neutral") { + check(`color_${family}_500`, "color_additional_50", `${family}_500 on additional_50`); + } + } + } +} + +/** Validate section (header/footer) text and link contrast. */ +function validateSectionContrast(darkPreset: PresetType, adjustments: IContrastAdjustment[]): void { + const check = (fg: string, bg: string, label: string) => checkAndFixContrast(darkPreset, adjustments, fg, bg, label); + const sections = ["header_top", "header_bottom", "footer_top", "footer_bottom"] as const; + const linkSuffixes = ["_link", "_link_hover", "_link_active"]; + + for (const section of sections) { + const bgKey = `color_${section}_bg`; + check(`color_${section}_text`, bgKey, `${section}_text on ${section}_bg`); + + for (const suffix of linkSuffixes) { + const linkKey = `color_${section}${suffix}`; + if (darkPreset[linkKey]) { + check(linkKey, bgKey, `${section}${suffix} on ${section}_bg`); + } + } + } +} + +/** Validate non-text contrast for input borders (WCAG 1.4.11: 3:1). */ +function validateBorderContrast(darkPreset: PresetType, adjustments: IContrastAdjustment[]): void { + const NON_TEXT_RATIO = 3; + const borderKey = "color_neutral_300"; + const surfaces = ["color_additional_50", "color_neutral_50", "color_body_bg"]; + + for (const bgKey of surfaces) { + checkAndFixContrast( + darkPreset, + adjustments, + borderKey, + bgKey, + `neutral_300 on ${bgKey.replace("color_", "")}`, + NON_TEXT_RATIO, + ); + } +} + +function validateAndFixContrast(darkPreset: PresetType): IContrastAdjustment[] { + const adjustments: IContrastAdjustment[] = []; + const check = (fg: string, bg: string, label: string) => checkAndFixContrast(darkPreset, adjustments, fg, bg, label); + + validatePaletteContrast(darkPreset, adjustments); + check("color_body_text", "color_body_bg", "body_text on body_bg"); + validateSectionContrast(darkPreset, adjustments); + validateBorderContrast(darkPreset, adjustments); + + // Mobile menu + if (darkPreset["color_mobile_menu_bg"]) { + check("color_mobile_menu_text", "color_mobile_menu_bg", "mobile_menu_text on mobile_menu_bg"); + check("color_mobile_menu_link", "color_mobile_menu_bg", "mobile_menu_link on mobile_menu_bg"); + if (darkPreset["color_mobile_menu_link_active"]) { + check("color_mobile_menu_link_active", "color_mobile_menu_bg", "mobile_menu_link_active on mobile_menu_bg"); + } + } + + return adjustments; +} + +// --------------------------------------------------------------------------- +// Main processing +// --------------------------------------------------------------------------- + +function processPreset(filePath: string): { outputPath: string; adjustments: IContrastAdjustment[] } { + const raw = readFileSync(filePath, "utf-8"); + const lightPreset: PresetType = JSON.parse(raw); + + const darkPreset: PresetType = {}; + + // 1. Invert palette shades + const invertedPalette = invertPaletteShades(lightPreset); + Object.assign(darkPreset, invertedPalette); + + // 2. Process semantic (non-palette) colors + for (const [key, value] of Object.entries(lightPreset)) { + if (isPaletteKey(key)) { + continue; // Already handled + } + darkPreset[key] = invertSemanticColor(key, value); + } + + // 3. Validate and fix WCAG AA contrast + const adjustments = validateAndFixContrast(darkPreset); + + // 4. Build output preserving original key order + const orderedDark: PresetType = {}; + for (const key of Object.keys(lightPreset)) { + if (key in darkPreset) { + orderedDark[key] = darkPreset[key]; + } + } + + // Write output file + const baseName = basename(filePath, ".json"); + const outputPath = join(PRESETS_DIR, `${baseName}.dark.json`); + writeFileSync(outputPath, JSON.stringify(orderedDark, null, 2) + "\n", "utf-8"); + + return { outputPath, adjustments }; +} + +function main(): void { + console.log("=== Dark Preset Generator ===\n"); + + // Find all light preset files (not *.dark.json) + const files = readdirSync(PRESETS_DIR).filter((f) => f.endsWith(".json") && !f.endsWith(".dark.json")); + + if (files.length === 0) { + console.log("No light preset files found in", PRESETS_DIR); + return; + } + + let totalAdjustments = 0; + + const sortedFiles = [...files].sort((a, b) => a.localeCompare(b)); + + for (const file of sortedFiles) { + const filePath = join(PRESETS_DIR, file); + const presetName = basename(file, ".json"); + + console.log(`Processing: ${presetName}`); + + const { outputPath, adjustments } = processPreset(filePath); + + if (adjustments.length > 0) { + for (const adj of adjustments) { + console.log( + ` [contrast fix] ${adj.pair}: ${adj.original.toFixed(2)} -> ${adj.adjusted.toFixed(2)} (adjusted ${adj.foregroundKey})`, + ); + } + totalAdjustments += adjustments.length; + } else { + console.log(" All contrast checks passed"); + } + + console.log(` -> ${relative(process.cwd(), outputPath)}`); + } + + console.log(`\n=== Summary ===`); + console.log(`Presets processed: ${files.length}`); + console.log(`Contrast adjustments: ${totalAdjustments}`); + console.log("Done."); +} + +main(); diff --git a/yarn.lock b/yarn.lock index 2268b7d6ec..7eb938e52f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4484,6 +4484,13 @@ __metadata: languageName: node linkType: hard +"@types/culori@npm:^4": + version: 4.0.1 + resolution: "@types/culori@npm:4.0.1" + checksum: 10c0/f25fcf01fe709d2fce569892eae71a268b19cf85a7c8f6bf6b041f02f232e5a7d1218e8213349d6b2a458be9f61158ed1e1893e7b81e2ee4a7cebc2a172b7ebb + languageName: node + linkType: hard + "@types/deep-eql@npm:*": version: 4.0.2 resolution: "@types/deep-eql@npm:4.0.2" @@ -7280,6 +7287,13 @@ __metadata: languageName: node linkType: hard +"culori@npm:^4.0.2": + version: 4.0.2 + resolution: "culori@npm:4.0.2" + checksum: 10c0/5d3c952b947ac5915409bab513e1d2e8bd813caa491af8ded0c769fdc1186a5ff61fb3c59fc8edf5393237d92074dbc54a492e6e99cb039dd6535d5d96106bfe + languageName: node + linkType: hard + "custom-event-polyfill@npm:^1.0.7": version: 1.0.7 resolution: "custom-event-polyfill@npm:1.0.7" @@ -15521,6 +15535,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/vue": "npm:^8.1.0" "@tsconfig/node22": "npm:^22.0.5" + "@types/culori": "npm:^4" "@types/dompurify": "npm:^3.2.0" "@types/google.maps": "npm:^3.58.1" "@types/gtag.js": "npm:^0.0.20" @@ -15546,6 +15561,7 @@ __metadata: barcode-detector: "npm:3.0.8" browserslist: "npm:^4.28.1" browserslist-to-esbuild: "npm:^2.1.1" + culori: "npm:^4.0.2" dependency-cruiser: "npm:^17.3.7" dompurify: "npm:^3.3.1" eslint: "npm:^9.39.2"