From 60c8c55ff2eabc4cfe27ed1e071ae73a0b0d66e5 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 21 May 2025 18:29:32 -0700 Subject: [PATCH] Add workaround to avoid duplicating theme css In order to support both a theme toggle and to automatically use the user's selected theme based on browser/system defaults the stylesheets has to be duplicated twice since the latter requires the css to be wrapped around a media query. This duplication is a painful experience to deal with when adding or changing existing styles. Even more so when it involves jumping around the behemoth default.css from and back to whatever sections you were just working on. I don't believe the we the Invidious team will be able to agree to a proper solution anytime soon (eg css post-processor, modern css light-dark feature, etc) so this commit is here as a stopgap measure. The workaround is to move the theming styles to two separate files which are read at runtime and used to generate a combined stylesheet with the necessary duplication for the media query. This combined stylesheet is then delivered on a new route added to Invidious, bypassing the static file handler. --- assets/css/carousel.css | 20 -- assets/css/dark.css | 81 +++++++ assets/css/default.css | 246 --------------------- assets/css/light.css | 67 ++++++ assets/css/search.css | 24 -- src/ext/kemal_static_file_handler.cr | 4 +- src/invidious/routes/get_theme_css_file.cr | 52 +++++ src/invidious/routing.cr | 1 + src/invidious/views/template.ecr | 1 + src/invidious/views/theme.css.ecr | 10 + 10 files changed, 215 insertions(+), 291 deletions(-) create mode 100644 assets/css/dark.css create mode 100644 assets/css/light.css create mode 100644 src/invidious/routes/get_theme_css_file.cr create mode 100644 src/invidious/views/theme.css.ecr diff --git a/assets/css/carousel.css b/assets/css/carousel.css index 4bae92e5..e34edc65 100644 --- a/assets/css/carousel.css +++ b/assets/css/carousel.css @@ -97,23 +97,3 @@ DEALINGS IN THE SOFTWARE. width: 50%; z-index: 1; } - -.light-theme .slider-nav { - background-color: #ddd; -} - -.dark-theme .slider-nav { - background-color: #0005; -} - -@media (prefers-color-scheme: light) { - .no-theme .slider-nav { - background-color: #ddd; - } -} - -@media (prefers-color-scheme: dark) { - .no-theme .slider-nav { - background-color: #0005; - } -} diff --git a/assets/css/dark.css b/assets/css/dark.css new file mode 100644 index 00000000..debe135f --- /dev/null +++ b/assets/css/dark.css @@ -0,0 +1,81 @@ +/* Contains the CSS for the Invidious dark theme + +-------- +This file should not get loaded by the user as the css here will be used +to generate theme.css as to support user-agent dark/light theme +in addition to the class-based swap method without duplicating any styles. +------ + +*/ + +.dark-theme a:hover, +.dark-theme a:active, +.dark-theme summary:hover, +.dark-theme a:focus, +.dark-theme summary:focus { + color: rgb(0, 182, 240); +} + +.dark-theme .pure-button-primary:hover, +.dark-theme .pure-button-primary:focus, +.dark-theme .pure-button-secondary:hover, +.dark-theme .pure-button-secondary:focus { + color: #fff !important; + border-color: rgb(0, 182, 240) !important; + background-color: rgba(0, 182, 240, 1) !important; +} + +.dark-theme .pure-button-secondary { + background-color: #0002; + color: #ddd; +} + +.dark-theme a { + color: #adadad; + text-decoration: none; +} + +body.dark-theme { + background-color: rgba(35, 35, 35, 1); + color: #f0f0f0; +} + +.dark-theme .pure-form legend, +.dark-theme .pure-menu-heading { + color: #f0f0f0; +} + +.dark-theme input, +.dark-theme select, +.dark-theme textarea { + color: rgba(35, 35, 35, 1); +} + +.dark-theme .pure-form input[type="file"] { + color: #f0f0f0; +} + +.dark-theme .searchbar input { + background-color: inherit; + color: inherit; +} + +.dark-theme .error-card { + border: 1px solid #5e5e5e; +} + +.dark-theme footer { + color: #adadad; +} + +.dark-theme footer a { + color: #adadad !important; +} + +.dark-theme #filters-box { + background: #373737; +} + +.dark-theme .slider-nav { + background-color: #0005; +} diff --git a/assets/css/default.css b/assets/css/default.css index 01d4b736..8411df1d 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -466,22 +466,6 @@ footer { max-height: 30vh; } -.light-theme footer { - color: #7c7c7c; -} - -.dark-theme footer { - color: #adadad; -} - -.light-theme footer a { - color: #7c7c7c !important; -} - -.dark-theme footer a { - color: #adadad !important; -} - footer span { margin: 4px 0; display: block; @@ -508,236 +492,6 @@ span > select { } -/* - * Light theme - */ - -.light-theme a:hover, -.light-theme a:active, -.light-theme summary:hover, -.light-theme a:focus, -.light-theme summary:focus { - color: #075A9E !important; -} - -.light-theme .pure-button-primary:hover, -.light-theme .pure-button-primary:focus, -.light-theme .pure-button-secondary:hover, -.light-theme .pure-button-secondary:focus { - color: #fff !important; - border-color: rgba(0, 182, 240, 0.75) !important; - background-color: rgba(0, 182, 240, 0.75) !important; -} - -.light-theme .pure-button-secondary:not(.low-profile) { - color: #335d7a; - background-color: #fff2; -} - -.light-theme a { - color: #335d7a; - text-decoration: none; -} - -/* All links that do not fit with the default color goes here */ -.light-theme a:not([data-id]) > .icon, -.light-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"], -.light-theme .playlist-restricted > ol > li > a { - color: #303030; -} - -.light-theme .pure-menu-heading { - color: #565d64; -} - -.light-theme .error-card { - border: 1px solid black; -} - -@media (prefers-color-scheme: light) { - .no-theme a:hover, - .no-theme a:active, - .no-theme summary:hover, - .no-theme a:focus, - .no-theme summary:focus { - color: #075A9E !important; - } - - .no-theme .pure-button-primary:hover, - .no-theme .pure-button-primary:focus, - .no-theme .pure-button-secondary:hover, - .no-theme .pure-button-secondary:focus { - color: #fff !important; - border-color: rgba(0, 182, 240, 0.75) !important; - background-color: rgba(0, 182, 240, 0.75) !important; - } - - .no-theme .pure-button-secondary:not(.low-profile) { - color: #335d7a; - background-color: #fff2; - } - - .no-theme a { - color: #335d7a; - text-decoration: none; - } - - /* All links that do not fit with the default color goes here */ - .no-theme a:not([data-id]) > .icon, - .no-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"], - .no-theme .playlist-restricted > ol > li > a { - color: #303030; - } - - .no-theme footer { - color: #7c7c7c; - } - - .no-theme footer a { - color: #7c7c7c !important; - } - - .light-theme .pure-menu-heading { - color: #565d64; - } - - .no-theme .error-card { - border: 1px solid black; - } -} - - -/* - * Dark theme - */ - -.dark-theme a:hover, -.dark-theme a:active, -.dark-theme summary:hover, -.dark-theme a:focus, -.dark-theme summary:focus { - color: rgb(0, 182, 240); -} - -.dark-theme .pure-button-primary:hover, -.dark-theme .pure-button-primary:focus, -.dark-theme .pure-button-secondary:hover, -.dark-theme .pure-button-secondary:focus { - color: #fff !important; - border-color: rgb(0, 182, 240) !important; - background-color: rgba(0, 182, 240, 1) !important; -} - -.dark-theme .pure-button-secondary { - background-color: #0002; - color: #ddd; -} - -.dark-theme a { - color: #adadad; - text-decoration: none; -} - -body.dark-theme { - background-color: rgba(35, 35, 35, 1); - color: #f0f0f0; -} - -.dark-theme .pure-form legend { - color: #f0f0f0; -} - -.dark-theme .pure-menu-heading { - color: #f0f0f0; -} - -.dark-theme input, -.dark-theme select, -.dark-theme textarea { - color: rgba(35, 35, 35, 1); -} - -.dark-theme .pure-form input[type="file"] { - color: #f0f0f0; -} - -.dark-theme .searchbar input { - background-color: inherit; - color: inherit; -} - -.dark-theme .error-card { - border: 1px solid #5e5e5e; -} - -@media (prefers-color-scheme: dark) { - .no-theme a:hover, - .no-theme a:active, - .no-theme a:focus { - color: rgb(0, 182, 240); - } - - .no-theme .pure-button-primary:hover, - .no-theme .pure-button-primary:focus, - .no-theme .pure-button-secondary:hover, - .no-theme .pure-button-secondary:focus { - color: #fff !important; - border-color: rgb(0, 182, 240) !important; - background-color: rgba(0, 182, 240, 1) !important; - } - - .no-theme .pure-button-secondary { - background-color: #0002; - color: #ddd; - } - - .no-theme a { - color: #adadad; - text-decoration: none; - } - - body.no-theme { - background-color: rgba(35, 35, 35, 1); - color: #f0f0f0; - } - - .no-theme .pure-form legend { - color: #f0f0f0; - } - - .no-theme .pure-menu-heading { - color: #f0f0f0; - } - - .no-theme input, - .no-theme select, - .no-theme textarea { - color: rgba(35, 35, 35, 1); - } - - .no-theme .pure-form input[type="file"] { - color: #f0f0f0; - } - - .no-theme .searchbar input { - background-color: inherit; - color: inherit; - } - - .no-theme footer { - color: #adadad; - } - - .no-theme footer a { - color: #adadad !important; - } - - .no-theme .error-card { - border: 1px solid #5e5e5e; - } -} - - /* * Miscellanous */ diff --git a/assets/css/light.css b/assets/css/light.css new file mode 100644 index 00000000..ccef24b3 --- /dev/null +++ b/assets/css/light.css @@ -0,0 +1,67 @@ +/* Contains the CSS for the Invidious light theme + +-------- +This file should not get loaded by the user as the css here will be used +to generate theme.css as to support user-agent dark/light theme +in addition to the class-based swap method without duplicating any styles. +------ + +*/ + +.light-theme a:hover, +.light-theme a:active, +.light-theme summary:hover, +.light-theme a:focus, +.light-theme summary:focus { + color: #075A9E !important; +} + +.light-theme .pure-button-primary:hover, +.light-theme .pure-button-primary:focus, +.light-theme .pure-button-secondary:hover, +.light-theme .pure-button-secondary:focus { + color: #fff !important; + border-color: rgba(0, 182, 240, 0.75) !important; + background-color: rgba(0, 182, 240, 0.75) !important; +} + +.light-theme .pure-button-secondary:not(.low-profile) { + color: #335d7a; + background-color: #fff2; +} + +.light-theme a { + color: #335d7a; + text-decoration: none; +} + +/* All links that do not fit with the default color goes here */ +.light-theme a:not([data-id]) > .icon, +.light-theme .pure-u-lg-1-5 > .h-box > a[href^="/watch?"], +.light-theme .playlist-restricted > ol > li > a { + color: #303030; +} + +.light-theme .pure-menu-heading { + color: #565d64; +} + +.light-theme footer { + color: #7c7c7c; +} + +.light-theme footer a { + color: #7c7c7c !important; +} + +.light-theme .error-card { + border: 1px solid black; +} + +.light-theme #filters-box { + background: #dfdfdf; +} + +.light-theme .slider-nav { + background-color: #ddd; +} diff --git a/assets/css/search.css b/assets/css/search.css index 833ec7e9..0c03a45e 100644 --- a/assets/css/search.css +++ b/assets/css/search.css @@ -95,27 +95,3 @@ fieldset, legend { padding: 15px; } } - -/* Light theme */ - -.light-theme #filters-box { - background: #dfdfdf; -} - -@media (prefers-color-scheme: light) { - .no-theme #filters-box { - background: #dfdfdf; - } -} - -/* Dark theme */ - -.dark-theme #filters-box { - background: #373737; -} - -@media (prefers-color-scheme: dark) { - .no-theme #filters-box { - background: #373737; - } -} diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr index a5f42261..db18592b 100644 --- a/src/ext/kemal_static_file_handler.cr +++ b/src/ext/kemal_static_file_handler.cr @@ -159,7 +159,9 @@ module Kemal redirect_to context, expanded_path + '/' end - return call_next(context) if file_info.nil? + return call_next(context) if file_info.nil? || + file_path.ends_with?("dark.css") || + file_path.ends_with?("light.css") if is_dir if config.is_a?(Hash) && config["dir_listing"] == true diff --git a/src/invidious/routes/get_theme_css_file.cr b/src/invidious/routes/get_theme_css_file.cr new file mode 100644 index 00000000..52cc322b --- /dev/null +++ b/src/invidious/routes/get_theme_css_file.cr @@ -0,0 +1,52 @@ +# No need to initialize yet another namespace +# +# :nodoc: +module Invidious::Routes::Misc + private CSS_THEME_LIGHT = File.read("assets/css/light.css") + private CSS_THEME_UA_LIGHT = CSS_THEME_LIGHT.gsub(".light-theme", ".no-theme") + + private CSS_THEME_DARK = File.read("assets/css/dark.css") + private CSS_THEME_UA_DARK = CSS_THEME_DARK.gsub(".dark-theme", ".no-theme") + + private THEME_LAST_MODIFIED = { + File.info("assets/css/light.css").modification_time, + File.info("assets/css/dark.css").modification_time, + }.min + + def self.theme_css(env) + env.response.headers["Content-Type"] = "text/css; charset=utf-8" + + # Replicate cache header behavior of static file handler + env.response.headers["Etag"] = %{W/"#{THEME_LAST_MODIFIED.to_unix}"} + env.response.headers["Last-Modified"] = HTTP.format_time(THEME_LAST_MODIFIED) + + if cache_request?(env, THEME_LAST_MODIFIED) + env.response.status = HTTP::Status::NOT_MODIFIED + return + end + + # Usually added in `send_file` when static_headers proc is called + env.response.headers["Cache-Control"] = "max-age=2629800" + + rendered "theme.css" + end + + # Taken from https://github.com/crystal-lang/crystal/blob/1.16.3/src/http/server/handlers/static_file_handler.cr#L236 + private def self.cache_request?(context : HTTP::Server::Context, last_modified : Time) : Bool + # According to RFC 7232: + # A recipient must ignore If-Modified-Since if the request contains an If-None-Match header field + if if_none_match = context.request.if_none_match + match = {"*", context.response.headers["Etag"]} + if_none_match.any? { |etag| match.includes?(etag) } + elsif if_modified_since = context.request.headers["If-Modified-Since"]? + header_time = HTTP.parse_time(if_modified_since) + # File mtime probably has a higher resolution than the header value. + # An exact comparison might be slightly off, so we add 1s padding. + # Static files should generally not be modified in subsecond intervals, so this is perfectly safe. + # This might be replaced by a more sophisticated time comparison when it becomes available. + !!(header_time && last_modified <= header_time + 1.second) + else + false + end + end +end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 46b71f1f..3588db4f 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -21,6 +21,7 @@ module Invidious::Routing get "/privacy", Routes::Misc, :privacy get "/licenses", Routes::Misc, :licenses get "/redirect", Routes::Misc, :cross_instance_redirect + get "/css/theme.css", Routes::Misc, :theme_css self.register_channel_routes self.register_watch_routes diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 9904b4fc..855496c4 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -21,6 +21,7 @@ + diff --git a/src/invidious/views/theme.css.ecr b/src/invidious/views/theme.css.ecr new file mode 100644 index 00000000..cb378ee3 --- /dev/null +++ b/src/invidious/views/theme.css.ecr @@ -0,0 +1,10 @@ +/* +This files contains an automatically generated CSS file that contains the styles +for the dark and light themes of Invidious. Automatically generating this file allows +supporting both the user-agent color scheme preference and class-based theme preference +without duplicating the defined styles twice for each theme. +*/ +<%=CSS_THEME_LIGHT-%> +@media (prefers-color-scheme: light) {<%= CSS_THEME_UA_LIGHT %>} +<%=CSS_THEME_DARK%> +@media (prefers-color-scheme: dark) {<%=CSS_THEME_UA_DARK%>}