From bc2f8a358f96a9540e6f39bb1c58273deb4545de Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 15 Jan 2026 17:04:27 +0100 Subject: [PATCH] Enable theming via new HTML element attributes (#37477) --- .storybook/preview-body.html | 2 +- app/helpers/application_helper.rb | 19 ++++++++ app/javascript/inline/theme-selection.js | 9 ++-- .../annual_report/announcement/index.tsx | 4 +- .../mastodon/features/annual_report/index.tsx | 2 +- .../mastodon/features/annual_report/modal.tsx | 7 +-- .../features/emoji/__tests__/emoji-test.js | 2 +- .../mastodon/features/emoji/emoji.js | 4 +- app/javascript/mastodon/utils/theme.ts | 12 ++--- .../styles/mastodon/theme/index.scss | 46 ++++++------------- app/views/layouts/application.html.haml | 2 +- app/views/wrapstodon/show.html.haml | 2 +- spec/requests/content_security_policy_spec.rb | 2 +- 13 files changed, 53 insertions(+), 60 deletions(-) diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index 7a92b6f95ff..7c078c0b3b7 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -1,2 +1,2 @@ - + \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 1076d9ced84..b23968e3731 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -89,6 +89,12 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end + def page_color_scheme + return content_for(:force_color_scheme) if content_for(:force_color_scheme) + + color_scheme + end + def label_for_scope(scope) safe_join [ tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }), @@ -153,6 +159,19 @@ module ApplicationHelper tag.meta(content: content, property: property) end + def html_attributes + base = { + lang: I18n.locale, + class: html_classes, + 'data-contrast': contrast.parameterize, + 'data-color-scheme': page_color_scheme.parameterize, + } + + base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto' + + base + end + def html_classes output = [] output << content_for(:html_classes) diff --git a/app/javascript/inline/theme-selection.js b/app/javascript/inline/theme-selection.js index b3a2b03163e..680fbb23ec2 100644 --- a/app/javascript/inline/theme-selection.js +++ b/app/javascript/inline/theme-selection.js @@ -1,16 +1,17 @@ (function (element) { - const {userTheme} = element.dataset; + const {colorScheme, contrast} = element.dataset; const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)'); const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)'); const updateColorScheme = () => { - const useDarkMode = userTheme === 'system' ? colorSchemeMediaWatcher.matches : userTheme !== 'mastodon-light'; - element.dataset.mode = useDarkMode ? 'dark' : 'light'; + const useDarkMode = colorScheme === 'auto' ? colorSchemeMediaWatcher.matches : colorScheme === 'dark'; + + element.dataset.colorScheme = useDarkMode ? 'dark' : 'light'; }; const updateContrast = () => { - const useHighContrast = userTheme === 'contrast' || contrastMediaWatcher.matches; + const useHighContrast = contrast === 'high' || contrastMediaWatcher.matches; element.dataset.contrast = useHighContrast ? 'high' : 'default'; } diff --git a/app/javascript/mastodon/features/annual_report/announcement/index.tsx b/app/javascript/mastodon/features/annual_report/announcement/index.tsx index 283e95f5940..d96b1092715 100644 --- a/app/javascript/mastodon/features/annual_report/announcement/index.tsx +++ b/app/javascript/mastodon/features/annual_report/announcement/index.tsx @@ -1,7 +1,5 @@ import { FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; - import type { ApiAnnualReportState } from '@/mastodon/api/annual_report'; import { Button } from '@/mastodon/components/button'; @@ -19,7 +17,7 @@ export const AnnualReportAnnouncement: React.FC< AnnualReportAnnouncementProps > = ({ year, state, onRequestBuild, onOpen, onDismiss }) => { return ( -
+
= ({ const topHashtag = report.data.top_hashtags[0]; return ( -
+

Wrapstodon {report.year}

{account &&

@{account.acct}

} diff --git a/app/javascript/mastodon/features/annual_report/modal.tsx b/app/javascript/mastodon/features/annual_report/modal.tsx index 01d7c4bbdbf..d591954bbf3 100644 --- a/app/javascript/mastodon/features/annual_report/modal.tsx +++ b/app/javascript/mastodon/features/annual_report/modal.tsx @@ -60,11 +60,8 @@ const AnnualReportModal: React.FC<{ // default modal backdrop, preventing clicks to pass through. // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
{!showAnnouncement ? ( diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js index 35804de82ae..5d0683dace8 100644 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js @@ -86,7 +86,7 @@ describe('emoji', () => { it('does an emoji containing ZWJ properly', () => { expect(emojify('šŸ’‚ā€ā™€ļøšŸ’‚ā€ā™‚ļø')) - .toEqual('šŸ’‚\u200Dā™€ļøšŸ’‚\u200Dā™‚ļø'); + .toEqual('šŸ’‚ā€ā™€ļøšŸ’‚ā€ā™‚ļø'); }); it('keeps ordering as expected (issue fixed by PR 20677)', () => { diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index f8fa0ae1923..859665a5312 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -1,6 +1,6 @@ import Trie from 'substring-trie'; -import { getUserTheme, isDarkMode } from '@/mastodon/utils/theme'; +import { getIsSystemTheme, isDarkMode } from '@/mastodon/utils/theme'; import { assetHost } from 'mastodon/utils/config'; import { autoPlayGif } from '../../initial_state'; @@ -98,7 +98,7 @@ const emojifyTextNode = (node, customEmojis) => { const { filename, shortCode } = unicodeMapping[unicode_emoji]; const title = shortCode ? `:${shortCode}:` : ''; - const isSystemTheme = getUserTheme() === 'system'; + const isSystemTheme = getIsSystemTheme(); const theme = (isSystemTheme || !isDarkMode()) ? 'light' : 'dark'; diff --git a/app/javascript/mastodon/utils/theme.ts b/app/javascript/mastodon/utils/theme.ts index 921787a6c43..494ee3cb53b 100644 --- a/app/javascript/mastodon/utils/theme.ts +++ b/app/javascript/mastodon/utils/theme.ts @@ -1,11 +1,9 @@ -export function getUserTheme() { - const { userTheme } = document.documentElement.dataset; - return userTheme; +export function getIsSystemTheme() { + const { systemTheme } = document.documentElement.dataset; + return systemTheme === 'true'; } export function isDarkMode() { - const { userTheme } = document.documentElement.dataset; - return userTheme === 'system' - ? window.matchMedia('(prefers-color-scheme: dark)').matches - : userTheme !== 'mastodon-light'; + const { colorScheme } = document.documentElement.dataset; + return colorScheme === 'dark'; } diff --git a/app/javascript/styles/mastodon/theme/index.scss b/app/javascript/styles/mastodon/theme/index.scss index a907299887d..a84b8b80da2 100644 --- a/app/javascript/styles/mastodon/theme/index.scss +++ b/app/javascript/styles/mastodon/theme/index.scss @@ -5,49 +5,29 @@ html { @include base.palette; - - &:where([data-user-theme='system']) { - color-scheme: dark light; - - @media (prefers-color-scheme: dark) { - @include dark.tokens; - @include utils.invert-on-dark; - - @media (prefers-contrast: more) { - @include dark.contrast-overrides; - } - } - - @media (prefers-color-scheme: light) { - @include light.tokens; - @include utils.invert-on-light; - - @media (prefers-contrast: more) { - @include light.contrast-overrides; - } - } - } } -.theme-dark, -html:where( - :not([data-user-theme='mastodon-light'], [data-user-theme='system']) -) { +[data-color-scheme='dark'], +html:not([data-color-scheme]) { color-scheme: dark; @include dark.tokens; @include utils.invert-on-dark; + + &[data-contrast='high'], + [data-contrast='high'] & { + @include dark.contrast-overrides; + } } -html[data-user-theme='contrast'], -html[data-user-theme='contrast'] .theme-dark { - @include dark.contrast-overrides; -} - -.theme-light, -html:where([data-user-theme='mastodon-light']) { +[data-color-scheme='light'] { color-scheme: light; @include light.tokens; @include utils.invert-on-light; + + &[data-contrast='high'], + [data-contrast='high'] & { + @include light.contrast-overrides; + } } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index dccb6035cae..0cbd51c85b0 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,5 +1,5 @@ !!! 5 -%html{ lang: I18n.locale, class: html_classes, 'data-user-theme': current_theme.parameterize, 'data-contrast': contrast.parameterize, 'data-mode': color_scheme.parameterize } +%html{ html_attributes } %head %meta{ charset: 'utf-8' }/ %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' }/ diff --git a/app/views/wrapstodon/show.html.haml b/app/views/wrapstodon/show.html.haml index ed1e64d4665..8a20001ad55 100644 --- a/app/views/wrapstodon/show.html.haml +++ b/app/views/wrapstodon/show.html.haml @@ -13,7 +13,7 @@ = vite_typescript_tag 'wrapstodon.tsx', crossorigin: 'anonymous' -- content_for :html_classes, 'theme-dark' +- content_for :force_color_scheme, 'dark' #wrapstodon = render_wrapstodon_share_data @generated_annual_report diff --git a/spec/requests/content_security_policy_spec.rb b/spec/requests/content_security_policy_spec.rb index 0aa4494ef0b..c84c3802f24 100644 --- a/spec/requests/content_security_policy_spec.rb +++ b/spec/requests/content_security_policy_spec.rb @@ -32,7 +32,7 @@ RSpec.describe 'Content-Security-Policy' do img-src 'self' data: blob: #{local_domain} manifest-src 'self' #{local_domain} media-src 'self' data: #{local_domain} - script-src 'self' #{local_domain} 'wasm-unsafe-eval' 'sha256-Q/2Cjx8v06hAdOF8/DeBUpsmBcSj7sLN3I/WpTF8T8c=' + script-src 'self' #{local_domain} 'wasm-unsafe-eval' 'sha256-Z5KW83D+6/pygIQS3h9XDpF52xW3l3BHc7JL9tj3uMs=' style-src 'self' #{local_domain} 'nonce-ZbA+JmE7+bK8F5qvADZHuQ==' worker-src 'self' blob: #{local_domain} CSP