From a1e9e398fb840fea1c53437340a2739755d23007 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 25 Feb 2026 21:11:32 +0100 Subject: [PATCH] Add Light Mode Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveSplit One now supports light mode in addition to dark mode. The theme automatically follows the system preference (`prefers-color-scheme`), but can also be explicitly set to light or dark via the settings. Changes: - **Theme setting**: A new "Theme" option in the settings lets users choose between Automatic (follows system), Light Mode, and Dark Mode. - **CSS variables**: All hardcoded dark-mode colors (backgrounds, borders, text, overlays, scrollbars, switches, etc.) have been extracted into CSS custom properties in variables.css, with a full set of light-mode overrides under `:root[data-theme="light"]` and a `prefers-color-scheme: light` media query fallback. - **Affected components**: main styles, buttons, text boxes, tables, select dropdowns, color picker, context menus, dialogs, tooltips, toasts, switches, splits selection, run editor, timer view, and the leaderboard. - **Leaderboard**: Player name colors now adapt to the active theme (using the light/dark variant from speedrun.com). - **Localization**: Theme labels translated in all supported languages. Changelog (en): LiveSplit One now supports light mode! You can change the theme in the settings. Changelog (de): LiveSplit One unterstützt jetzt einen hellen Modus! Das Design kann in den Einstellungen geändert werden. Changelog (fr): LiveSplit One prend désormais en charge le mode clair ! Vous pouvez changer le thème dans les Paramètres. Changelog (nl): LiveSplit One ondersteunt nu een lichte modus! Je kunt het thema wijzigen in de Instellingen. Changelog (es): ¡LiveSplit One ahora soporta el Modo Claro! Puedes cambiar el Tema en la Configuración. Changelog (it): LiveSplit One ora supporta la modalità chiara! Puoi cambiare il tema nelle Impostazioni. Changelog (pt): O LiveSplit One agora suporta o Modo Claro! Pode alterar o Tema nas Configurações. Changelog (pt-BR): O LiveSplit One agora suporta o Modo Claro! Você pode alterar o Tema nas Configurações. Changelog (pl): LiveSplit One obsługuje teraz tryb jasny! Motyw można zmienić w Ustawieniach. Changelog (ru): LiveSplit One теперь поддерживает светлый режим! Вы можете изменить тему в Настройках. Changelog (ja): LiveSplit Oneがライトモードに対応しました!テーマは設定から変更できます。 Changelog (ko): LiveSplit One이 이제 라이트 모드를 지원합니다! 설정에서 테마를 변경할 수 있습니다. Changelog (zh-Hans): LiveSplit One 现已支持浅色模式!您可以在设置中更改主题。 Changelog (zh-Hant): LiveSplit One 現已支持淺色模式!您可以在設定中更改主題。 --- src/css/ColorPicker.module.css | 20 ++-- src/css/ContextMenu.module.css | 11 +- src/css/Dialog.module.css | 10 +- src/css/RunEditor.module.css | 2 +- src/css/SplitsSelection.module.css | 4 +- src/css/Switch.module.css | 10 +- src/css/Table.module.css | 30 ++++- src/css/TextBox.module.css | 16 +-- src/css/TimerView.module.css | 2 +- src/css/Toast.module.css | 6 +- src/css/Tooltip.module.css | 6 +- src/css/main.css | 10 +- src/css/variables.css | 139 ++++++++++++++++++++++++ src/localization/chinese-simplified.ts | 4 + src/localization/chinese-traditional.ts | 1 + src/localization/dutch.ts | 4 + src/localization/english.ts | 4 + src/localization/french.ts | 6 +- src/localization/german.ts | 4 + src/localization/index.ts | 4 + src/localization/italian.ts | 4 + src/localization/japanese.ts | 4 + src/localization/korean.ts | 4 + src/localization/polish.ts | 4 + src/localization/portuguese-brazil.ts | 6 +- src/localization/portuguese.ts | 12 +- src/localization/russian.ts | 4 + src/localization/spanish.ts | 6 +- src/storage/index.ts | 2 + src/ui/LiveSplit.tsx | 46 +++++++- src/ui/components/Leaderboard.tsx | 26 ++++- src/ui/views/MainSettings.tsx | 62 ++++++++++- 32 files changed, 409 insertions(+), 64 deletions(-) diff --git a/src/css/ColorPicker.module.css b/src/css/ColorPicker.module.css index 371497af6..5a23aabf5 100644 --- a/src/css/ColorPicker.module.css +++ b/src/css/ColorPicker.module.css @@ -1,5 +1,5 @@ .colorPickerButton { - border: 2px solid white; + border: 2px solid var(--color-button-border-color); border-radius: 2px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); cursor: pointer; @@ -19,13 +19,13 @@ } .glassPanel { - background-color: rgba(28, 28, 28, 0.8); + background-color: var(--overlay-background-color); backdrop-filter: blur(5px); z-index: 1; position: absolute; margin-top: 5px; margin-left: -113.5px; - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid var(--overlay-border-color); box-shadow: 0 5px 10px 0px rgba(28, 28, 28, 0.8); } @@ -33,7 +33,7 @@ margin: 0; height: 1px; border-width: 0px; - background: rgba(255, 255, 255, 0.25); + background: var(--overlay-border-color); } .gradientSelector { @@ -143,13 +143,13 @@ /* Hack for LSO to prevent global style */ all: revert; appearance: none; - accent-color: white; + accent-color: var(--overlay-input-text-color); width: calc(100% - 2px); } } .colorPreview { - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid var(--overlay-border-color); border-radius: 50%; background: white; position: relative; @@ -169,7 +169,7 @@ flex-direction: column; gap: 5px; font-size: 12px; - color: rgba(255, 255, 255, 0.7); + color: var(--overlay-muted-text-color); align-items: center; flex: 1; width: 0; @@ -182,10 +182,10 @@ font-family: inherit; font-size: 14px; text-align: center; - color: white; + color: var(--overlay-input-text-color); font-variant-numeric: tabular-nums; background: transparent; - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid var(--overlay-input-border-color); border-radius: 5px; padding: 0; } @@ -220,7 +220,7 @@ &:hover { /* Hack for LSO to prevent global button style */ background: revert; - border: 3px solid white; + border: 3px solid var(--overlay-input-text-color); } &:active { diff --git a/src/css/ContextMenu.module.css b/src/css/ContextMenu.module.css index f7eea52ae..fe112b10e 100644 --- a/src/css/ContextMenu.module.css +++ b/src/css/ContextMenu.module.css @@ -5,18 +5,19 @@ .panel { z-index: 3; - background-color: rgba(28, 28, 28, 0.8); + background-color: var(--overlay-background-color); backdrop-filter: blur(5px); min-width: 100px; padding: 4px 0; - border: 1px solid rgba(255, 255, 255, 0.25); + border: 1px solid var(--overlay-border-color); border-radius: 9px; - box-shadow: 0 5px 10px 0px rgba(28, 28, 28, 0.8); + box-shadow: 0 5px 10px 0px var(--overlay-shadow-color); display: flex; flex-direction: column; user-select: none; text-align: left; font-weight: initial; + color: var(--overlay-text-color); } .overlay { @@ -33,13 +34,13 @@ margin: 0 4px; &:hover { - background-color: rgba(255, 255, 255, 0.25); + background-color: var(--overlay-hover-color); } } .hr { height: 1px; border-width: 0px; - background: rgba(255, 255, 255, 0.25); + background: var(--overlay-border-color); margin: 4px 0; } diff --git a/src/css/Dialog.module.css b/src/css/Dialog.module.css index 69bbb772a..5e4c54830 100644 --- a/src/css/Dialog.module.css +++ b/src/css/Dialog.module.css @@ -12,10 +12,10 @@ dialog { } .dialog { - color: #eee; - background-color: rgba(28, 28, 28, 0.8); + color: var(--overlay-text-color); + background-color: var(--overlay-background-color); backdrop-filter: blur(5px); - border: 2px solid rgba(255, 255, 255, 0.25); + border: 2px solid var(--overlay-border-color); border-radius: 10px; min-width: 225px; display: flex; @@ -54,9 +54,9 @@ dialog { input { border: none; - border-bottom: 1px solid rgba(255, 255, 255, 0.25); + border-bottom: 1px solid var(--overlay-input-border-color); background: transparent; - color: white; + color: var(--overlay-input-text-color); text-overflow: ellipsis; font-family: "fira", sans-serif; font-size: 16px; diff --git a/src/css/RunEditor.module.css b/src/css/RunEditor.module.css index b192d5368..33d722895 100644 --- a/src/css/RunEditor.module.css +++ b/src/css/RunEditor.module.css @@ -244,5 +244,5 @@ .bestSegmentTime { /* FIXME: important */ - color: hsla(50, 100%, 50%, 1) !important; + color: var(--best-segment-color) !important; } diff --git a/src/css/SplitsSelection.module.css b/src/css/SplitsSelection.module.css index 8e47ae298..05852c725 100644 --- a/src/css/SplitsSelection.module.css +++ b/src/css/SplitsSelection.module.css @@ -57,10 +57,12 @@ &.selected { background: var(--selected-row-color); + color: var(--selected-row-text-color); } &.selected .splitsRowButtons button { opacity: 70%; + color: var(--selected-row-text-color); } :global(.is-mobile) & { @@ -81,7 +83,7 @@ opacity: 50%; margin: 0; transition: 0.3s; - color: white; + color: var(--main-text-color); padding: 0; &:hover { diff --git a/src/css/Switch.module.css b/src/css/Switch.module.css index e9879e614..ee2181bba 100644 --- a/src/css/Switch.module.css +++ b/src/css/Switch.module.css @@ -13,7 +13,7 @@ span { position: absolute; - background-color: #ffffff20; + background-color: var(--switch-track-color); border-radius: 20px; top: 0; left: 0; @@ -23,7 +23,7 @@ } span::before { - background-color: white; + background-color: var(--switch-thumb-color); border-radius: 50%; content: ""; position: absolute; @@ -35,15 +35,15 @@ } &:hover input + span { - background-color: #ffffff40; + background-color: var(--switch-track-hover-color); } &:hover input:checked + span { - background-color: #ffffff80; + background-color: var(--switch-track-checked-hover-color); } input:checked + span { - background-color: #ffffff60; + background-color: var(--switch-track-checked-color); } input:checked + span::before { diff --git a/src/css/Table.module.css b/src/css/Table.module.css index f17301b99..336a84726 100644 --- a/src/css/Table.module.css +++ b/src/css/Table.module.css @@ -51,11 +51,11 @@ input.textBox { font-family: inherit; background: transparent; - color: white; + color: var(--input-text-color); text-overflow: ellipsis; font-size: 15px; border: none; - border-bottom: 1px solid hsla(0, 0%, 100%, 0.25); + border-bottom: 1px solid var(--input-border-color); } input[Type="text"] { @@ -64,17 +64,27 @@ select { background: var(--dark-row-color) - url("data:image/svg+xml;charset=UTF-8,%3Csvg width='12' height='12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='0,4 2,4 5,7 8,4 10,4 5,9' fill='%23fff'/%3E%3C/svg%3E") + url("data:image/svg+xml;charset=UTF-8,%3Csvg width='12' height='12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='0,4 2,4 5,7 8,4 10,4 5,9' fill='%23eeeeee'/%3E%3C/svg%3E") no-repeat right 4px center; font-size: 15px; border: 1px solid var(--border-color); - color: white; + color: var(--input-text-color); text-overflow: ellipsis; font-family: inherit; padding-left: 4px; padding-right: 20px; border-radius: 0; appearance: none; + + :root[data-theme="light"] & { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg width='12' height='12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='0,4 2,4 5,7 8,4 10,4 5,9' fill='%232b3442'/%3E%3C/svg%3E"); + } + + @media (prefers-color-scheme: light) { + :root:not([data-theme]) & { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg width='12' height='12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='0,4 2,4 5,7 8,4 10,4 5,9' fill='%232b3442'/%3E%3C/svg%3E"); + } + } } } @@ -86,6 +96,18 @@ .selected { background: var(--selected-row-color) !important; + color: var(--selected-row-text-color); + --best-segment-color: var(--best-segment-selected-color); + + input.textBox, + select, + a { + color: var(--selected-row-text-color); + } + + input.textBox { + border-bottom-color: rgba(255, 255, 255, 0.5); + } } .settingsTable { diff --git a/src/css/TextBox.module.css b/src/css/TextBox.module.css index 1ede3c961..956f107a5 100644 --- a/src/css/TextBox.module.css +++ b/src/css/TextBox.module.css @@ -4,13 +4,13 @@ > input { font-size: 18px; - padding: calc(14px + var(--ui-margin)) 0 - calc(var(--ui-margin) / 2) calc(var(--ui-margin) / 2); + padding: calc(14px + var(--ui-margin)) 0 calc(var(--ui-margin) / 2) + calc(var(--ui-margin) / 2); display: block; width: 100%; border: none; background: transparent; - color: white; + color: var(--input-text-color); font-family: inherit; } @@ -19,7 +19,7 @@ } > label { - color: hsla(50, 0%, 75%, 1); + color: var(--field-label-color); font-size: 14px; font-weight: normal; position: absolute; @@ -30,11 +30,11 @@ } > input:focus ~ label { - color: hsla(50, 100%, 50%, 1); + color: var(--field-label-focus-color); } &.invalid > input:focus ~ label { - color: hsla(0, 100%, 50%, 1); + color: var(--field-label-invalid-color); } > .bar { @@ -50,7 +50,7 @@ width: 0; bottom: 0; position: absolute; - background: hsla(50, 100%, 50%, 1); + background: var(--field-highlight-color); transition: 0.2s ease all; } @@ -64,7 +64,7 @@ &.invalid > .bar:before, &.invalid > .bar:after { - background: hsla(0, 100%, 50%, 1); + background: var(--field-highlight-invalid-color); } > input:focus ~ .bar:before, diff --git a/src/css/TimerView.module.css b/src/css/TimerView.module.css index fd34c536b..fd3c91768 100644 --- a/src/css/TimerView.module.css +++ b/src/css/TimerView.module.css @@ -67,7 +67,7 @@ background: var(--button-middle-color); border: 1px solid var(--border-color); border-radius: 5px; - color: white; + color: var(--button-text-color); cursor: pointer; font-family: "fira", sans-serif; font-size: 20px; diff --git a/src/css/Toast.module.css b/src/css/Toast.module.css index a7267e2cf..46a7114ea 100644 --- a/src/css/Toast.module.css +++ b/src/css/Toast.module.css @@ -1,9 +1,10 @@ :global(.Toastify) { .toastClass { font-family: "fira", sans-serif; - background: rgba(28, 28, 28, 0.8) !important; + background: var(--overlay-background-color) !important; backdrop-filter: blur(5px); - border: 2px solid rgba(255, 255, 255, 0.25) !important; + border: 2px solid var(--overlay-border-color) !important; + color: var(--overlay-text-color); border-radius: 10px !important; min-height: 48px !important; padding-right: 28px; @@ -24,5 +25,6 @@ .toastBody { margin: 5px 5px 5px 5px !important; + color: var(--overlay-text-color); } } diff --git a/src/css/Tooltip.module.css b/src/css/Tooltip.module.css index 6195dde71..2ddd630cd 100644 --- a/src/css/Tooltip.module.css +++ b/src/css/Tooltip.module.css @@ -4,10 +4,10 @@ .tooltipText { visibility: hidden; width: 300px; - background-color: rgba(28, 28, 28, 0.8); + background-color: var(--overlay-background-color); backdrop-filter: blur(5px); - border: 1px solid rgba(255, 255, 255, 0.25); - color: #fff; + border: 1px solid var(--overlay-border-color); + color: var(--overlay-text-color); text-align: center; border-radius: 6px; padding: var(--ui-margin); diff --git a/src/css/main.css b/src/css/main.css index 17548c8f6..faeef0be4 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -12,8 +12,8 @@ body { font-family: "fira", sans-serif; - color: #eee; - text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5); + color: var(--main-text-color); + text-shadow: var(--main-text-shadow); background: var(--main-background-color); } @@ -67,7 +67,7 @@ button { } a { - color: #56b0ff; + color: var(--link-color); text-decoration: none; &:hover { text-decoration: underline; @@ -82,7 +82,7 @@ a { ::-webkit-scrollbar-thumb { background-clip: padding-box; - background-color: #303030; + background-color: var(--scrollbar-thumb-color); border: 0 solid #0000; border-radius: 10px; } @@ -92,7 +92,7 @@ a { } ::-webkit-scrollbar-corner { - background-color: hsla(0, 0%, 9%, 1); + background-color: var(--scrollbar-corner-color); } .initial-load { diff --git a/src/css/variables.css b/src/css/variables.css index 307fcebc3..db22ec8c1 100644 --- a/src/css/variables.css +++ b/src/css/variables.css @@ -26,10 +26,40 @@ --button-max-width: 300px; --button-height: 40px; + --color-button-border-color: #fff; + --manual-game-time-height: 25px; --main-background-color: #171717; --sidebar-background-color: #1a1a1a; + --main-text-color: #eee; + --main-text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5); + --link-color: #56b0ff; + --scrollbar-thumb-color: #303030; + --scrollbar-corner-color: hsla(0, 0%, 9%, 1); + --input-text-color: var(--main-text-color); + --input-border-color: hsla(0, 0%, 100%, 0.25); + --field-label-color: hsla(50, 0%, 75%, 1); + --field-label-focus-color: hsla(50, 100%, 50%, 1); + --field-label-invalid-color: hsla(0, 100%, 50%, 1); + --field-highlight-color: hsla(50, 100%, 50%, 1); + --field-highlight-invalid-color: hsla(0, 100%, 50%, 1); + --best-segment-color: var(--field-highlight-color); + --best-segment-selected-color: hsla(50, 100%, 50%, 1); + --select-arrow-color: var(--main-text-color); + --switch-track-color: #ffffff20; + --switch-track-hover-color: #ffffff40; + --switch-track-checked-color: #ffffff60; + --switch-track-checked-hover-color: #ffffff80; + --switch-thumb-color: #fff; + --overlay-background-color: rgba(28, 28, 28, 0.8); + --overlay-border-color: rgba(255, 255, 255, 0.25); + --overlay-text-color: #eee; + --overlay-muted-text-color: rgba(255, 255, 255, 0.7); + --overlay-input-border-color: rgba(255, 255, 255, 0.25); + --overlay-input-text-color: #fff; + --overlay-hover-color: rgba(255, 255, 255, 0.25); + --overlay-shadow-color: rgba(28, 28, 28, 0.8); --dark-row-color: #0b0b0b; --header-row-color: #090909; @@ -44,4 +74,113 @@ hsl(220, 90%, 70%) 0%, hsl(220, 69%, 40%) 100% ); + --selected-row-text-color: #fff; +} + +:root[data-theme="light"] { + --border-color: #b8bcc2; + + --button-active-color: linear-gradient(#cfd5dd 0%, #b4bcc8 100%); + --button-color: linear-gradient(#f5f7fb 0%, #dbe2ec 100%); + --button-middle-color: #dde3ec; + --button-disabled-color: #e5e8ed; + --button-disabled-text-color: #8a9097; + --button-hover-color: linear-gradient(#ffffff 0%, #e8eef7 100%); + --button-text-color: #202733; + + --color-button-border-color: var(--overlay-border-color); + + --main-background-color: #eef1f6; + --sidebar-background-color: #e4e8ef; + --main-text-color: #202733; + --main-text-shadow: none; + --link-color: #0a58ca; + --scrollbar-thumb-color: #bcc4d1; + --scrollbar-corner-color: #d8dee8; + --input-text-color: var(--main-text-color); + --input-border-color: #8e99aa; + --field-label-color: #5f6d83; + --field-label-focus-color: oklch(70% 0.25 94); + --field-label-invalid-color: #b3261e; + --field-highlight-color: oklch(70% 0.25 94); + --field-highlight-invalid-color: #b3261e; + --select-arrow-color: #2b3442; + --switch-track-color: #9ca8ba; + --switch-track-hover-color: #8f9db1; + --switch-track-checked-color: #3d74e0; + --switch-track-checked-hover-color: #3167d1; + --switch-thumb-color: #fff; + --overlay-background-color: rgba(245, 248, 252, 0.8); + --overlay-border-color: rgba(43, 52, 66, 0.25); + --overlay-text-color: #202733; + --overlay-muted-text-color: #3a475c; + --overlay-input-border-color: #8e99aa; + --overlay-input-text-color: #202733; + --overlay-hover-color: rgba(43, 52, 66, 0.12); + --overlay-shadow-color: rgba(43, 52, 66, 0.28); + + --dark-row-color: #d9e0ea; + --header-row-color: #cfd8e4; + --hover-row-color: #c3cedd; + --light-row-color: #e7edf5; + + --selected-row-color: linear-gradient(#4b7be1 0%, #2f5fca 100%); + --selected-row-hover-color: linear-gradient(#5688ea 0%, #3769d3 100%); + --selected-row-text-color: #fff; +} + +/* FIXME: Possibly deduplicate this in the future with CSS if() once that's +supported in all browsers */ +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + --border-color: #b8bcc2; + + --button-active-color: linear-gradient(#cfd5dd 0%, #b4bcc8 100%); + --button-color: linear-gradient(#f5f7fb 0%, #dbe2ec 100%); + --button-middle-color: #dde3ec; + --button-disabled-color: #e5e8ed; + --button-disabled-text-color: #8a9097; + --button-hover-color: linear-gradient(#ffffff 0%, #e8eef7 100%); + --button-text-color: #202733; + + --color-button-border-color: var(--overlay-border-color); + + --main-background-color: #eef1f6; + --sidebar-background-color: #e4e8ef; + --main-text-color: #202733; + --main-text-shadow: none; + --link-color: #0a58ca; + --scrollbar-thumb-color: #bcc4d1; + --scrollbar-corner-color: #d8dee8; + --input-text-color: var(--main-text-color); + --input-border-color: #8e99aa; + --field-label-color: #5f6d83; + --field-label-focus-color: oklch(70% 0.25 94); + --field-label-invalid-color: #b3261e; + --field-highlight-color: oklch(70% 0.25 94); + --field-highlight-invalid-color: #b3261e; + --select-arrow-color: #2b3442; + --switch-track-color: #9ca8ba; + --switch-track-hover-color: #8f9db1; + --switch-track-checked-color: #3d74e0; + --switch-track-checked-hover-color: #3167d1; + --switch-thumb-color: #fff; + --overlay-background-color: rgba(245, 248, 252, 0.8); + --overlay-border-color: rgba(43, 52, 66, 0.25); + --overlay-text-color: #202733; + --overlay-muted-text-color: #3a475c; + --overlay-input-border-color: #8e99aa; + --overlay-input-text-color: #202733; + --overlay-hover-color: rgba(43, 52, 66, 0.12); + --overlay-shadow-color: rgba(43, 52, 66, 0.28); + + --dark-row-color: #d9e0ea; + --header-row-color: #cfd8e4; + --hover-row-color: #c3cedd; + --light-row-color: #e7edf5; + + --selected-row-color: linear-gradient(#4b7be1 0%, #2f5fca 100%); + --selected-row-hover-color: linear-gradient(#5688ea 0%, #3769d3 100%); + --selected-row-text-color: #fff; + } } diff --git a/src/localization/chinese-simplified.ts b/src/localization/chinese-simplified.ts index 43ce26aa2..1e598e4e5 100644 --- a/src/localization/chinese-simplified.ts +++ b/src/localization/chinese-simplified.ts @@ -8,6 +8,10 @@ export function resolveChineseSimplified(text: Label): string { case Label.Language: return "语言"; case Label.LanguageDescription: return "设置应用程序使用的语言。"; case Label.LanguageAuto: return "自动"; + case Label.Theme: return "主题"; + case Label.ThemeDescription: return "决定是跟随系统主题,还是强制使用浅色或深色模式。"; + case Label.ThemeLightMode: return "浅色模式"; + case Label.ThemeDarkMode: return "深色模式"; case Label.HotkeysHeading: return "热键"; case Label.GeneralHeading: return "常规"; case Label.NetworkHeading: return "网络"; diff --git a/src/localization/chinese-traditional.ts b/src/localization/chinese-traditional.ts index 84f3cf248..c9520f3f3 100644 --- a/src/localization/chinese-traditional.ts +++ b/src/localization/chinese-traditional.ts @@ -6,6 +6,7 @@ export function resolveChineseTraditional(text: Label): string { } const TRADITIONAL_REPLACEMENTS: [string, string][] = [ + ["重置时保存", "重設時保存"], ["游戏时间", "遊戲時間"], ["实时时间", "實時時間"], ["计时器", "計時器"], diff --git a/src/localization/dutch.ts b/src/localization/dutch.ts index eb8296628..5f239d406 100644 --- a/src/localization/dutch.ts +++ b/src/localization/dutch.ts @@ -8,6 +8,10 @@ export function resolveDutch(text: Label): string { case Label.Language: return "Taal"; case Label.LanguageDescription: return "Stelt de taal in die in de applicatie wordt gebruikt."; case Label.LanguageAuto: return "Automatisch"; + case Label.Theme: return "Thema"; + case Label.ThemeDescription: return "Bepaalt of het systeemthema wordt gevolgd of dat een lichte of donkere modus wordt afgedwongen."; + case Label.ThemeLightMode: return "Lichte modus"; + case Label.ThemeDarkMode: return "Donkere modus"; case Label.HotkeysHeading: return "Sneltoetsen"; case Label.GeneralHeading: return "Algemeen"; case Label.NetworkHeading: return "Netwerk"; diff --git a/src/localization/english.ts b/src/localization/english.ts index c2d93a4fa..53e053fe7 100644 --- a/src/localization/english.ts +++ b/src/localization/english.ts @@ -8,6 +8,10 @@ export function resolveEnglish(text: Label): string { case Label.Language: return "Language"; case Label.LanguageDescription: return "Sets the language used in the application."; case Label.LanguageAuto: return "Automatic"; + case Label.Theme: return "Theme"; + case Label.ThemeDescription: return "Determines whether to follow the system theme or force a light or dark mode."; + case Label.ThemeLightMode: return "Light Mode"; + case Label.ThemeDarkMode: return "Dark Mode"; case Label.HotkeysHeading: return "Hotkeys"; case Label.GeneralHeading: return "General"; case Label.NetworkHeading: return "Network"; diff --git a/src/localization/french.ts b/src/localization/french.ts index bcca87c6f..e947df6c5 100644 --- a/src/localization/french.ts +++ b/src/localization/french.ts @@ -8,6 +8,10 @@ export function resolveFrench(text: Label): string { case Label.Language: return "Langue"; case Label.LanguageDescription: return "Définit la langue utilisée dans l'application."; case Label.LanguageAuto: return "Automatique"; + case Label.Theme: return "Thème"; + case Label.ThemeDescription: return "Détermine s’il faut suivre le thème du système ou forcer le mode clair ou sombre."; + case Label.ThemeLightMode: return "Mode clair"; + case Label.ThemeDarkMode: return "Mode sombre"; case Label.HotkeysHeading: return "Raccourcis"; case Label.GeneralHeading: return "Général"; case Label.NetworkHeading: return "Réseau"; @@ -38,7 +42,7 @@ export function resolveFrench(text: Label): string { case Label.Pause: return "Pause"; case Label.UndoSplit: return "Annuler le split"; case Label.Reset: return "Réinitialiser"; - case Label.SkipSplit: return "Ignorer le split"; + case Label.SkipSplit: return "Passer le split"; case Label.ManualGameTimePlaceholder: return "Temps de jeu manuel"; case Label.LiveSplitLogoAlt: return "Logo LiveSplit"; case Label.LiveSplitOne: return "LiveSplit One"; diff --git a/src/localization/german.ts b/src/localization/german.ts index 2b748210d..1739aaa8c 100644 --- a/src/localization/german.ts +++ b/src/localization/german.ts @@ -8,6 +8,10 @@ export function resolveGerman(text: Label): string { case Label.Language: return "Sprache"; case Label.LanguageDescription: return "Legt die in der Anwendung verwendete Sprache fest."; case Label.LanguageAuto: return "Automatisch"; + case Label.Theme: return "Design"; + case Label.ThemeDescription: return "Legt fest, ob das Systemdesign übernommen oder der helle bzw. dunkle Modus erzwungen wird."; + case Label.ThemeLightMode: return "Heller Modus"; + case Label.ThemeDarkMode: return "Dunkler Modus"; case Label.HotkeysHeading: return "Tastenkürzel"; case Label.GeneralHeading: return "Allgemein"; case Label.NetworkHeading: return "Netzwerk"; diff --git a/src/localization/index.ts b/src/localization/index.ts index 464205079..6d89caca6 100644 --- a/src/localization/index.ts +++ b/src/localization/index.ts @@ -21,6 +21,10 @@ export enum Label { Language, LanguageDescription, LanguageAuto, + Theme, + ThemeDescription, + ThemeLightMode, + ThemeDarkMode, HotkeysHeading, GeneralHeading, NetworkHeading, diff --git a/src/localization/italian.ts b/src/localization/italian.ts index 8977b0f37..053282f5e 100644 --- a/src/localization/italian.ts +++ b/src/localization/italian.ts @@ -8,6 +8,10 @@ export function resolveItalian(text: Label): string { case Label.Language: return "Lingua"; case Label.LanguageDescription: return "Imposta la lingua utilizzata nell'applicazione."; case Label.LanguageAuto: return "Automatico"; + case Label.Theme: return "Tema"; + case Label.ThemeDescription: return "Determina se seguire il tema di sistema o forzare una modalità chiara o scura."; + case Label.ThemeLightMode: return "Modalità chiara"; + case Label.ThemeDarkMode: return "Modalità scura"; case Label.HotkeysHeading: return "Scorciatoie"; case Label.GeneralHeading: return "Generale"; case Label.NetworkHeading: return "Rete"; diff --git a/src/localization/japanese.ts b/src/localization/japanese.ts index 16710707b..906f318c3 100644 --- a/src/localization/japanese.ts +++ b/src/localization/japanese.ts @@ -8,6 +8,10 @@ export function resolveJapanese(text: Label): string { case Label.Language: return "言語"; case Label.LanguageDescription: return "アプリケーションで使用される言語を設定します。"; case Label.LanguageAuto: return "自動"; + case Label.Theme: return "テーマ"; + case Label.ThemeDescription: return "システムのテーマに従うか、ライトモードまたはダークモードを強制するかを決定します。"; + case Label.ThemeLightMode: return "ライトモード"; + case Label.ThemeDarkMode: return "ダークモード"; case Label.HotkeysHeading: return "ホットキー"; case Label.GeneralHeading: return "一般"; case Label.NetworkHeading: return "ネットワーク"; diff --git a/src/localization/korean.ts b/src/localization/korean.ts index d3c4079a8..b1606b5a0 100644 --- a/src/localization/korean.ts +++ b/src/localization/korean.ts @@ -8,6 +8,10 @@ export function resolveKorean(text: Label): string { case Label.Language: return "언어"; case Label.LanguageDescription: return "애플리케이션에서 사용되는 언어를 설정합니다."; case Label.LanguageAuto: return "자동"; + case Label.Theme: return "테마"; + case Label.ThemeDescription: return "시스템 테마를 따르거나 라이트 또는 다크 모드를 강제할지 결정합니다."; + case Label.ThemeLightMode: return "라이트 모드"; + case Label.ThemeDarkMode: return "다크 모드"; case Label.HotkeysHeading: return "단축키"; case Label.GeneralHeading: return "일반"; case Label.NetworkHeading: return "네트워크"; diff --git a/src/localization/polish.ts b/src/localization/polish.ts index 42b74b5f1..4a6d25b1a 100644 --- a/src/localization/polish.ts +++ b/src/localization/polish.ts @@ -8,6 +8,10 @@ export function resolvePolish(text: Label): string { case Label.Language: return "Język"; case Label.LanguageDescription: return "Ustawia język używany w aplikacji."; case Label.LanguageAuto: return "Automatyczny"; + case Label.Theme: return "Motyw"; + case Label.ThemeDescription: return "Określa, czy używać motywu systemowego, czy wymusić tryb jasny lub ciemny."; + case Label.ThemeLightMode: return "Tryb jasny"; + case Label.ThemeDarkMode: return "Tryb ciemny"; case Label.HotkeysHeading: return "Skróty klawiszowe"; case Label.GeneralHeading: return "Ogólne"; case Label.NetworkHeading: return "Sieć"; diff --git a/src/localization/portuguese-brazil.ts b/src/localization/portuguese-brazil.ts index d1cccb851..d4ff1a0a0 100644 --- a/src/localization/portuguese-brazil.ts +++ b/src/localization/portuguese-brazil.ts @@ -8,6 +8,10 @@ export function resolveBrazilianPortuguese(text: Label): string { case Label.Language: return "Idioma"; case Label.LanguageDescription: return "Define o idioma usado no aplicativo."; case Label.LanguageAuto: return "Automático"; + case Label.Theme: return "Tema"; + case Label.ThemeDescription: return "Determina se deve seguir o tema do sistema ou forçar um modo claro ou escuro."; + case Label.ThemeLightMode: return "Modo Claro"; + case Label.ThemeDarkMode: return "Modo Escuro"; case Label.HotkeysHeading: return "Teclas de atalho"; case Label.GeneralHeading: return "Geral"; case Label.NetworkHeading: return "Rede"; @@ -37,7 +41,7 @@ export function resolveBrazilianPortuguese(text: Label): string { case Label.Resume: return "Retomar"; case Label.Pause: return "Pausar"; case Label.UndoSplit: return "Desfazer split"; - case Label.Reset: return "Redefinir"; + case Label.Reset: return "Resetar"; case Label.SkipSplit: return "Pular split"; case Label.ManualGameTimePlaceholder: return "Tempo de jogo manual"; case Label.LiveSplitLogoAlt: return "Logo do LiveSplit"; diff --git a/src/localization/portuguese.ts b/src/localization/portuguese.ts index b411e21fa..2791d45c5 100644 --- a/src/localization/portuguese.ts +++ b/src/localization/portuguese.ts @@ -8,6 +8,10 @@ export function resolvePortuguese(text: Label): string { case Label.Language: return "Idioma"; case Label.LanguageDescription: return "Define o idioma usado no aplicativo."; case Label.LanguageAuto: return "Automático"; + case Label.Theme: return "Tema"; + case Label.ThemeDescription: return "Determina se deve seguir o tema do sistema ou forçar um modo claro ou escuro."; + case Label.ThemeLightMode: return "Modo Claro"; + case Label.ThemeDarkMode: return "Modo Escuro"; case Label.HotkeysHeading: return "Teclas de atalho"; case Label.GeneralHeading: return "Geral"; case Label.NetworkHeading: return "Rede"; @@ -15,8 +19,8 @@ export function resolvePortuguese(text: Label): string { case Label.FrameRateDescription: return "Determina a taxa de quadros em que o timer é exibido. “Sensível à bateria” tenta determinar o tipo de dispositivo e o estado de carregamento para selecionar uma boa taxa de quadros. “Correspondente à tela” faz o timer corresponder à taxa de atualização da tela."; case Label.FrameRateBatteryAware: return "Sensível à bateria"; case Label.FrameRateMatchScreen: return "Correspondente à tela"; - case Label.SaveOnReset: return "Salvar ao resetar"; - case Label.SaveOnResetDescription: return "Determina se os splits devem ser salvos automaticamente ao resetar o timer."; + case Label.SaveOnReset: return "Guardar ao reiniciar"; + case Label.SaveOnResetDescription: return "Determina se os splits devem ser guardados automaticamente ao reiniciar o temporizador."; case Label.ShowControlButtons: return "Mostrar botões de controle"; case Label.ShowControlButtonsDescription: return "Determina se deve mostrar botões abaixo do timer que permitem controlá‑lo. Quando desativado, você deve usar as teclas de atalho."; case Label.ShowManualGameTimeInput: return "Mostrar entrada manual do tempo de jogo"; @@ -37,8 +41,8 @@ export function resolvePortuguese(text: Label): string { case Label.Resume: return "Retomar"; case Label.Pause: return "Pausar"; case Label.UndoSplit: return "Desfazer split"; - case Label.Reset: return "Redefinir"; - case Label.SkipSplit: return "Pular split"; + case Label.Reset: return "Reiniciar"; + case Label.SkipSplit: return "Ignorar split"; case Label.ManualGameTimePlaceholder: return "Tempo de jogo manual"; case Label.LiveSplitLogoAlt: return "Logo do LiveSplit"; case Label.LiveSplitOne: return "LiveSplit One"; diff --git a/src/localization/russian.ts b/src/localization/russian.ts index 382631097..2bfbebddd 100644 --- a/src/localization/russian.ts +++ b/src/localization/russian.ts @@ -8,6 +8,10 @@ export function resolveRussian(text: Label): string { case Label.Language: return "Язык"; case Label.LanguageDescription: return "Устанавливает язык, используемый в приложении."; case Label.LanguageAuto: return "Автоматически"; + case Label.Theme: return "Тема"; + case Label.ThemeDescription: return "Определяет, следовать ли системной теме или принудительно включить светлый или тёмный режим."; + case Label.ThemeLightMode: return "Светлый режим"; + case Label.ThemeDarkMode: return "Тёмный режим"; case Label.HotkeysHeading: return "Горячие клавиши"; case Label.GeneralHeading: return "Общие"; case Label.NetworkHeading: return "Сеть"; diff --git a/src/localization/spanish.ts b/src/localization/spanish.ts index 224deb2b8..426e1c1b3 100644 --- a/src/localization/spanish.ts +++ b/src/localization/spanish.ts @@ -8,6 +8,10 @@ export function resolveSpanish(text: Label): string { case Label.Language: return "Idioma"; case Label.LanguageDescription: return "Establece el idioma utilizado en la aplicación."; case Label.LanguageAuto: return "Automático"; + case Label.Theme: return "Tema"; + case Label.ThemeDescription: return "Determina si se sigue el tema del sistema o se fuerza el modo claro u oscuro."; + case Label.ThemeLightMode: return "Modo Claro"; + case Label.ThemeDarkMode: return "Modo Oscuro"; case Label.HotkeysHeading: return "Atajos de teclado"; case Label.GeneralHeading: return "General"; case Label.NetworkHeading: return "Red"; @@ -38,7 +42,7 @@ export function resolveSpanish(text: Label): string { case Label.Pause: return "Pausar"; case Label.UndoSplit: return "Deshacer split"; case Label.Reset: return "Reiniciar"; - case Label.SkipSplit: return "Saltar split"; + case Label.SkipSplit: return "Omitir split"; case Label.ManualGameTimePlaceholder: return "Tiempo de juego manual"; case Label.LiveSplitLogoAlt: return "Logotipo de LiveSplit"; case Label.LiveSplitOne: return "LiveSplit One"; diff --git a/src/storage/index.ts b/src/storage/index.ts index 2e54c19ec..8895b856d 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -4,6 +4,7 @@ import { RunRef, Run, TimingMethod, Language } from "../livesplit-core"; import { GeneralSettings, MANUAL_GAME_TIME_SETTINGS_DEFAULT, + THEME_MODE_AUTOMATIC, } from "../ui/views/MainSettings"; import { FRAME_RATE_AUTOMATIC } from "../util/FrameRate"; import { fromLocaleOpt, getLocaleOpt, setHtmlLang } from "../localization"; @@ -272,6 +273,7 @@ export async function loadGeneralSettings(): Promise { setHtmlLang(lang); return { + themeMode: generalSettings.themeMode ?? THEME_MODE_AUTOMATIC, frameRate: generalSettings.frameRate ?? FRAME_RATE_AUTOMATIC, showControlButtons: generalSettings.showControlButtons ?? !isTauri, showManualGameTime: generalSettings.showManualGameTime ?? false, diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index e7874e8bc..fd135ee55 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -36,6 +36,8 @@ import { RunEditor as RunEditorComponent } from "./views/RunEditor"; import { GeneralSettings, MainSettings as SettingsEditorComponent, + THEME_MODE_AUTOMATIC, + THEME_MODE_DARK, } from "./views/MainSettings"; import { TimerView } from "./views/TimerView"; import { About } from "./views/About"; @@ -180,6 +182,9 @@ export class LiveSplit extends React.Component { } private isDesktopQuery = window.matchMedia("(min-width: 600px)"); + private colorSchemeQuery = window.matchMedia( + "(prefers-color-scheme: dark)", + ); private scrollEvent: Option; private rightClickEvent: Option; private resizeEvent: Option; @@ -276,6 +281,7 @@ export class LiveSplit extends React.Component { }); this.updateTauriSettings(props.generalSettings); + this.applyTheme(props.generalSettings); this.updateBadge(); @@ -309,6 +315,10 @@ export class LiveSplit extends React.Component { window.addEventListener("contextmenu", this.rightClickEvent, false); this.resizeEvent = { handleEvent: () => this.handleAutomaticResize() }; window.addEventListener("resize", this.resizeEvent, false); + this.colorSchemeQuery.addEventListener( + "change", + this.colorSchemeChanged, + ); window.onbeforeunload = () => { if (this.state.splitsModified || this.state.layoutModified) { @@ -375,6 +385,10 @@ export class LiveSplit extends React.Component { // eslint-disable-next-line @typescript-eslint/unbound-method this.mediaQueryChanged, ); + this.colorSchemeQuery.removeEventListener( + "change", + this.colorSchemeChanged, + ); const { serviceWorker } = navigator; if (serviceWorker) { @@ -509,12 +523,40 @@ export class LiveSplit extends React.Component { position="bottom-right" toastClassName={toastClasses.toastClass} className={toastClasses.toastBody} - theme="dark" + theme={ + this.getResolvedTheme() === THEME_MODE_DARK + ? "dark" + : "light" + } /> ); } + private colorSchemeChanged = () => { + if (this.state.generalSettings.themeMode === THEME_MODE_AUTOMATIC) { + this.forceUpdate(); + } + }; + + private applyTheme(generalSettings: GeneralSettings) { + const root = document.documentElement; + + if (generalSettings.themeMode === THEME_MODE_AUTOMATIC) { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", generalSettings.themeMode); + } + } + + private getResolvedTheme() { + if (this.state.generalSettings.themeMode === THEME_MODE_AUTOMATIC) { + return this.colorSchemeQuery.matches ? THEME_MODE_DARK : "light"; + } + + return this.state.generalSettings.themeMode; + } + public renderViewWithSidebar( renderedView: React.JSX.Element, sidebarContent: React.JSX.Element, @@ -797,8 +839,10 @@ export class LiveSplit extends React.Component { this.state.hotkeySystem.setConfig(menu.config); this.setState({ generalSettings }); this.updateTauriSettings(generalSettings); + this.applyTheme(generalSettings); } else { setHtmlLang(this.state.generalSettings.lang); + this.applyTheme(this.state.generalSettings); menu.config[Symbol.dispose](); } diff --git a/src/ui/components/Leaderboard.tsx b/src/ui/components/Leaderboard.tsx index db8191142..3e49da112 100644 --- a/src/ui/components/Leaderboard.tsx +++ b/src/ui/components/Leaderboard.tsx @@ -27,6 +27,20 @@ export interface Filters { variables: Map; } +function getThemeStyleVariant(): "light" | "dark" { + const root = document.documentElement; + const forcedTheme = root.getAttribute("data-theme"); + if (forcedTheme === "light") { + return "light"; + } + if (forcedTheme === "dark") { + return "dark"; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + export function Leaderboard({ game, category, @@ -44,6 +58,8 @@ export function Leaderboard({ toggleExpandLeaderboardRow: (rowIndex: number) => void; lang: Language | undefined; }) { + const themeStyleVariant = getThemeStyleVariant(); + const gameInfo = getGameInfo(game); const platformList = getPlatforms(); const regionList = getRegions(); @@ -293,9 +309,13 @@ export function Leaderboard({ const style = p["name-style"]; let color; if (style.style === "gradient") { - color = style["color-from"].dark; + color = + style["color-from"][ + themeStyleVariant + ]; } else { - color = style.color.dark; + color = + style.color[themeStyleVariant]; } const flag = map(p.location, (l) => replaceFlag(l.country.code), @@ -345,7 +365,7 @@ export function Leaderboard({ e.stopPropagation()} > {formatLeaderboardTime( diff --git a/src/ui/views/MainSettings.tsx b/src/ui/views/MainSettings.tsx index d86f35ccc..e4acd5c68 100644 --- a/src/ui/views/MainSettings.tsx +++ b/src/ui/views/MainSettings.tsx @@ -27,6 +27,7 @@ import buttonGroupClasses from "../../css/ButtonGroup.module.css"; import { Label, orAutoLang, resolve, setHtmlLang } from "../../localization"; export interface GeneralSettings { + themeMode: ThemeMode; frameRate: FrameRateSetting; showControlButtons: boolean; showManualGameTime: ManualGameTimeSettings | false; @@ -37,6 +38,24 @@ export interface GeneralSettings { lang: Language | undefined; } +export type ThemeMode = + | typeof THEME_MODE_AUTOMATIC + | typeof THEME_MODE_LIGHT + | typeof THEME_MODE_DARK; + +export const THEME_MODE_AUTOMATIC = "automatic"; +export const THEME_MODE_LIGHT = "light"; +export const THEME_MODE_DARK = "dark"; + +export function previewThemeMode(themeMode: ThemeMode) { + const root = document.documentElement; + if (themeMode === THEME_MODE_AUTOMATIC) { + root.removeAttribute("data-theme"); + } else { + root.setAttribute("data-theme", themeMode); + } +} + export interface ManualGameTimeSettings { mode: string; } @@ -166,6 +185,24 @@ export function View({ }, }, }, + { + text: resolve(Label.Theme, lang), + tooltip: resolve(Label.ThemeDescription, lang), + value: { + CustomCombobox: { + value: generalSettings.themeMode, + list: [ + [ + THEME_MODE_AUTOMATIC, + resolve(Label.AlignmentAutomatic, lang), + ], + [THEME_MODE_LIGHT, resolve(Label.ThemeLightMode, lang)], + [THEME_MODE_DARK, resolve(Label.ThemeDarkMode, lang)], + ] as [string, string][], + mandatory: true, + }, + }, + }, { text: resolve(Label.FrameRate, lang), tooltip: resolve(Label.FrameRateDescription, lang), @@ -305,6 +342,25 @@ export function View({ } break; case 1: + if ("String" in value) { + const themeMode = value.String as ThemeMode; + if ( + themeMode !== THEME_MODE_AUTOMATIC && + themeMode !== THEME_MODE_LIGHT && + themeMode !== THEME_MODE_DARK + ) { + break; + } + + previewThemeMode(themeMode); + + setGeneralSettings({ + ...generalSettings, + themeMode, + }); + } + break; + case 2: if ("String" in value) { setGeneralSettings({ ...generalSettings, @@ -321,7 +377,7 @@ export function View({ }); } break; - case 2: + case 3: if ("Bool" in value) { setGeneralSettings({ ...generalSettings, @@ -329,7 +385,7 @@ export function View({ }); } break; - case 3: + case 4: if ("Bool" in value) { setGeneralSettings({ ...generalSettings, @@ -337,7 +393,7 @@ export function View({ }); } break; - case 4: + case 5: if ("Bool" in value) { setGeneralSettings({ ...generalSettings,