From b528ff9c59eca59d631946b2f5af8eafa368d000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 7 May 2026 11:42:45 +0900 Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=81=AE=E9=81=A9=E7=94=A8=E7=AE=A1=E7=90=86=E3=82=92?= =?UTF-8?q?=E6=94=B9=E5=96=84=20(#17376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * add test * use themeManager.currentCompiledTheme for obtaining theme variables / reduce getComputedStyle usage * fix * fix: better error handling on theme installation * Update Changelog * chore: remove frontend-shared builds as it is currently working as a stub package * fix: broken lockfile * fix * fix lint * fix --- CHANGELOG.md | 4 + locales/ja-JP.yml | 2 + packages/frontend-embed/src/boot.ts | 2 +- packages/frontend-embed/src/theme.ts | 67 +-- packages/frontend-shared/@types/theme.d.ts | 13 + packages/frontend-shared/build.js | 109 ----- packages/frontend-shared/js/theme.ts | 126 ++++++ packages/frontend-shared/package.json | 21 +- packages/frontend/.storybook/preview.ts | 8 +- packages/frontend/src/boot/common.ts | 10 +- .../frontend/src/components/MkAnalogClock.vue | 14 +- .../src/components/MkCropperDialog.vue | 6 +- .../src/components/MkFoldableSection.vue | 6 +- packages/frontend/src/components/MkFolder.vue | 5 +- .../src/components/MkInstanceStats.vue | 3 +- .../frontend/src/components/MkMiniChart.vue | 4 +- .../src/components/MkRetentionLineChart.vue | 3 +- .../frontend/src/components/MkTagCloud.vue | 4 +- .../src/components/MkThemePreview.vue | 4 +- .../MkVisitorDashboard.ActiveUsersChart.vue | 5 +- packages/frontend/src/components/MkWindow.vue | 20 +- .../src/directives/adaptive-border.ts | 6 +- packages/frontend/src/directives/panel.ts | 3 +- packages/frontend/src/events.ts | 2 - .../frontend/src/pages/admin/overview.pie.vue | 3 +- .../frontend/src/pages/install-extensions.vue | 3 +- .../src/pages/settings/theme.install.vue | 33 +- .../src/pages/settings/theme.manage.vue | 14 +- .../frontend/src/pages/settings/theme.vue | 14 +- packages/frontend/src/pages/theme-editor.vue | 11 +- packages/frontend/src/preferences/def.ts | 2 +- packages/frontend/src/theme.ts | 394 ++++++++---------- .../src/ui/_common_/ThemePreviewing.vue | 57 +++ packages/frontend/src/ui/deck.vue | 3 + packages/frontend/src/ui/universal.vue | 3 + .../frontend/src/utility/code-highlighter.ts | 2 +- packages/frontend/src/utility/init-chart.ts | 3 +- packages/frontend/src/utility/popout.ts | 5 +- packages/frontend/src/utility/theme-editor.ts | 4 +- packages/frontend/test/theme.test.ts | 188 +++++++++ packages/frontend/test/tsconfig.json | 6 +- packages/i18n/src/autogen/locale.ts | 8 + pnpm-lock.yaml | 18 +- scripts/clean-all.mjs | 1 - scripts/clean.mjs | 1 - scripts/dev.mjs | 6 - 46 files changed, 722 insertions(+), 504 deletions(-) create mode 100644 packages/frontend-shared/@types/theme.d.ts delete mode 100644 packages/frontend-shared/build.js create mode 100644 packages/frontend-shared/js/theme.ts create mode 100644 packages/frontend/src/ui/_common_/ThemePreviewing.vue create mode 100644 packages/frontend/test/theme.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b713d617f3..f80ab9991f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ ### Client - Enhance: ノートの詳細表示での公開範囲の表示を改善 (Cherry-picked from https://github.com/kokonect-link/cherrypick/commit/ecc75563f4e428b66adccc379bf317b5b21ed8e6) +- Enhance: テーマのプレビュー時、リロードせずにもとのテーマに戻せるように +- Fix: テーマエディター使用時に、最初の変更のみ適用される問題を修正 +- Fix: テーマのプレビュー時、既存のテーマとIDが被っている場合にプレビューできない問題を修正 +- Fix: テーマのインストールエラーの表示を改善 - Fix: ロール設定画面でロールをアサイン/アサイン解除した際、リロードしなくても画面に反映されるよう修正 ### Server diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5512deb972..b100bae66b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1409,6 +1409,8 @@ presets: "プリセット" zeroPadding: "ゼロ埋め" nothingToConfigure: "設定項目はありません" viewRenotedChannel: "リノート先のチャンネルを見る" +previewingTheme: "テーマのプレビュー中" +previewingThemeRestore: "元に戻す" _imageEditing: _vars: diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index 961cbcef66..fd8c0b1261 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -28,7 +28,7 @@ import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; import { serverContext } from '@/server-context.js'; import { i18n } from '@/i18n.js'; -import type { Theme } from '@/theme.js'; +import type { Theme } from '@@/js/theme.js'; console.log('Misskey Embed'); diff --git a/packages/frontend-embed/src/theme.ts b/packages/frontend-embed/src/theme.ts index c7bc5df85d..07d2ad7a02 100644 --- a/packages/frontend-embed/src/theme.ts +++ b/packages/frontend-embed/src/theme.ts @@ -5,26 +5,10 @@ // TODO: (可能な部分を)sharedに抽出して frontend と共通化 -import tinycolor from 'tinycolor2'; import lightTheme from '@@/themes/_light.json5'; import darkTheme from '@@/themes/_dark.json5'; -import type { BundledTheme } from 'shiki/themes'; - -export type Theme = { - id: string; - name: string; - author: string; - desc?: string; - base?: 'dark' | 'light'; - props: Record; - codeHighlighter?: { - base: BundledTheme; - overrides?: Record; - } | { - base: '_none_'; - overrides: Record; - }; -}; +import { compile } from '@@/js/theme.js'; +import type { Theme } from '@@/js/theme.js'; let timeout: number | null = null; @@ -32,7 +16,7 @@ export function assertIsTheme(theme: Record): theme is Theme { return typeof theme === 'object' && theme !== null && 'id' in theme && 'name' in theme && 'author' in theme && 'props' in theme; } -export function applyTheme(theme: Theme, persist = true) { +export function applyTheme(theme: Theme) { if (timeout) window.clearTimeout(timeout); window.document.documentElement.classList.add('_themeChanging_'); @@ -68,48 +52,3 @@ export function applyTheme(theme: Theme, persist = true) { // iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照 } - -function compile(theme: Theme): Record { - function getColor(val: string): tinycolor.Instance { - if (val[0] === '@') { // ref (prop) - return getColor(theme.props[val.substring(1)]); - } else if (val[0] === '$') { // ref (const) - return getColor(theme.props[val]); - } else if (val[0] === ':') { // func - const parts = val.split('<'); - const funcTxt = parts.shift(); - const argTxt = parts.shift(); - - if (funcTxt && argTxt) { - const func = funcTxt.substring(1); - const arg = parseFloat(argTxt); - const color = getColor(parts.join('<')); - - switch (func) { - case 'darken': return color.darken(arg); - case 'lighten': return color.lighten(arg); - case 'alpha': return color.setAlpha(arg); - case 'hue': return color.spin(arg); - case 'saturate': return color.saturate(arg); - } - } - } - - // other case - return tinycolor(val); - } - - const props = {}; - - for (const [k, v] of Object.entries(theme.props)) { - if (k.startsWith('$')) continue; // ignore const - - props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); - } - - return props; -} - -function genValue(c: tinycolor.Instance): string { - return c.toRgbString(); -} diff --git a/packages/frontend-shared/@types/theme.d.ts b/packages/frontend-shared/@types/theme.d.ts new file mode 100644 index 0000000000..e136d1808f --- /dev/null +++ b/packages/frontend-shared/@types/theme.d.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +declare module '@@/themes/*.json5' { + import { Theme } from '@@/js/theme.js'; + + const theme: Theme; + + // eslint-disable-next-line import/no-default-export + export default theme; +} diff --git a/packages/frontend-shared/build.js b/packages/frontend-shared/build.js deleted file mode 100644 index 1f98267468..0000000000 --- a/packages/frontend-shared/build.js +++ /dev/null @@ -1,109 +0,0 @@ -import fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as esbuild from 'esbuild'; -import { build } from 'esbuild'; -import { execa } from 'execa'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); -const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8')); - -const entryPoints = fs.globSync('./js/**/**.{ts,tsx}'); - -/** @type {import('esbuild').BuildOptions} */ -const options = { - entryPoints, - minify: process.env.NODE_ENV === 'production', - outdir: './js-built', - target: 'es2022', - platform: 'browser', - format: 'esm', - sourcemap: 'linked', -}; - -const args = process.argv.slice(2).map(arg => arg.toLowerCase()); - -// js-built配下をすべて削除する -if (!args.includes('--no-clean')) { - fs.rmSync('./js-built', { recursive: true, force: true }); -} - -if (args.includes('--watch')) { - await watchSrc(); -} else { - await buildSrc(); -} - -async function buildSrc() { - console.log(`[${_package.name}] start building...`); - - await build(options) - .then(() => { - console.log(`[${_package.name}] build succeeded.`); - }) - .catch((err) => { - process.stderr.write(err.stderr); - process.exit(1); - }); - - if (process.env.NODE_ENV === 'production') { - console.log(`[${_package.name}] skip building d.ts because NODE_ENV is production.`); - } else { - await buildDts(); - } - - fs.copyFileSync('./js/emojilist.json', './js-built/emojilist.json'); - - console.log(`[${_package.name}] finish building.`); -} - -function buildDts() { - return execa( - 'tsgo', - [ - '--project', 'tsconfig.json', - '--outDir', 'js-built', - '--declaration', 'true', - '--emitDeclarationOnly', 'true', - ], - { - stdout: process.stdout, - stderr: process.stderr, - }, - ); -} - -async function watchSrc() { - const plugins = [{ - name: 'gen-dts', - setup(build) { - build.onStart(() => { - console.log(`[${_package.name}] detect changed...`); - }); - build.onEnd(async result => { - if (result.errors.length > 0) { - console.error(`[${_package.name}] watch build failed:`, result); - return; - } - await buildDts(); - }); - }, - }]; - - console.log(`[${_package.name}] start watching...`); - - const context = await esbuild.context({ ...options, plugins }); - await context.watch(); - - await new Promise((resolve, reject) => { - process.on('SIGHUP', resolve); - process.on('SIGINT', resolve); - process.on('SIGTERM', resolve); - process.on('uncaughtException', reject); - process.on('exit', resolve); - }).finally(async () => { - await context.dispose(); - console.log(`[${_package.name}] finish watching.`); - }); -} diff --git a/packages/frontend-shared/js/theme.ts b/packages/frontend-shared/js/theme.ts new file mode 100644 index 0000000000..1e41ebcb93 --- /dev/null +++ b/packages/frontend-shared/js/theme.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import tinycolor from 'tinycolor2'; +import JSON5 from 'json5'; +import lightTheme from '@@/themes/_light.json5'; +import type { BundledTheme } from 'shiki/themes'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + kind?: 'dark' | 'light'; // legacy + props: Record; + codeHighlighter?: { + base: BundledTheme; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + overrides?: Record; + } | { + base: '_none_'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + overrides: Record; + }; +}; + +export type CompiledTheme = Record; + +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + +export const getBuiltinThemes = () => Promise.all( + [ + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-botanical', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', + ].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)), +); + +export function compile(theme: Theme): CompiledTheme { + function getColor(val: string): tinycolor.Instance { + if (val[0] === '@') { // ref (prop) + return getColor(theme.props[val.substring(1)]); + } else if (val[0] === '$') { // ref (const) + return getColor(theme.props[val]); + } else if (val[0] === ':') { // func + const parts = val.split('<'); + const funcTxt = parts.shift(); + const argTxt = parts.shift(); + + if (funcTxt && argTxt) { + const func = funcTxt.substring(1); + const arg = parseFloat(argTxt); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + } + + // other case + return tinycolor(val); + } + + const props = {} as CompiledTheme; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function validateTheme(theme: Record): boolean { + if (theme.id == null || typeof theme.id !== 'string') return false; + if (theme.name == null || typeof theme.name !== 'string') return false; + if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false; + if (theme.props == null || typeof theme.props !== 'object') return false; + return true; +} + +export function parseThemeCode(code: string): Theme { + let theme; + + try { + theme = JSON5.parse(code); + } catch (_) { + throw new Error('Failed to parse theme json'); + } + if (!validateTheme(theme)) { + throw new Error('This theme is invaild'); + } + + return theme; +} diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index d7e6462352..1452f3bd09 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -1,32 +1,18 @@ { "name": "frontend-shared", "type": "module", - "main": "./js-built/index.js", - "types": "./js-built/index.d.ts", - "exports": { - ".": { - "import": "./js-built/index.js", - "types": "./js-built/index.d.ts" - }, - "./*": { - "import": "./js-built/*", - "types": "./js-built/*" - } - }, + "private": true, "scripts": { - "build": "node ./build.js", - "watch": "nodemon -w package.json -e json --exec \"node ./build.js --watch\"", "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "devDependencies": { "@types/node": "24.12.2", + "@types/tinycolor2": "1.4.6", "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", - "esbuild": "0.28.0", "eslint-plugin-vue": "10.9.0", - "nodemon": "3.1.14", "vue-eslint-parser": "10.4.0" }, "files": [ @@ -34,7 +20,10 @@ ], "dependencies": { "i18n": "workspace:*", + "json5": "2.2.3", "misskey-js": "workspace:*", + "shiki": "4.0.2", + "tinycolor2": "1.6.0", "vue": "3.5.33" } } diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index 2aac8af400..9508f450ff 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -20,13 +20,13 @@ let moduleInitialized = false; let unobserve = () => {}; let misskeyOS = null; -function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) { +function loadTheme(themeMaganer: typeof import('../src/theme')['themeManager']) { unobserve(); const theme = themes[window.document.documentElement.dataset.misskeyTheme]; if (theme) { - applyTheme(themes[window.document.documentElement.dataset.misskeyTheme]); + themeMaganer.updateTheme(themes[window.document.documentElement.dataset.misskeyTheme]); } else { - applyTheme(themes['l-light']); + themeMaganer.updateTheme(themes['l-light']); } const observer = new MutationObserver((entries) => { for (const entry of entries) { @@ -34,7 +34,7 @@ function loadTheme(applyTheme: typeof import('../src/theme')['applyTheme']) { const target = entry.target as HTMLElement; const theme = themes[target.dataset.misskeyTheme]; if (theme) { - applyTheme(themes[target.dataset.misskeyTheme]); + themeMaganer.updateTheme(themes[target.dataset.misskeyTheme]); } else { target.removeAttribute('style'); } diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index 2b522d3f10..fa60ec4b58 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -13,7 +13,7 @@ import type { App } from 'vue'; import widgets from '@/widgets/index.js'; import directives from '@/directives/index.js'; import components from '@/components/index.js'; -import { applyTheme } from '@/theme.js'; +import { themeManager } from '@/theme.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { i18n } from '@/i18n.js'; import { refreshCurrentAccount, login } from '@/accounts.js'; @@ -161,7 +161,7 @@ export async function common(createVue: () => Promise>) { // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) // NOTE: この処理は必ずダークモード判定処理より後に来ること(初回のテーマ適用のため) - // NOTE: この処理は必ずサーバーテーマ適用処理より後に来ること(二重applyTheme発火を防ぐため) + // NOTE: この処理は必ずサーバーテーマ適用処理より後に来ること(二重発火を防ぐため) // see: https://github.com/misskey-dev/misskey/issues/16562 watch(store.r.darkMode, (darkMode) => { const theme = (() => { @@ -172,7 +172,7 @@ export async function common(createVue: () => Promise>) { } })(); - applyTheme(theme); + themeManager.updateTheme(theme); }, { immediate: true }); window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light'; @@ -180,13 +180,13 @@ export async function common(createVue: () => Promise>) { if (!isSafeMode) { watch(prefer.r.darkTheme, (theme) => { if (store.s.darkMode) { - applyTheme(theme ?? defaultDarkTheme); + themeManager.updateTheme(theme ?? defaultDarkTheme); } }); watch(prefer.r.lightTheme, (theme) => { if (!store.s.darkMode) { - applyTheme(theme ?? defaultLightTheme); + themeManager.updateTheme(theme ?? defaultLightTheme); } }); } diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index eac1ea9534..f1e6fea9fb 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -81,7 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 1fad936d16..c0fe75a594 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -34,7 +34,7 @@ import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import * as os from '@/os.js'; +import { themeManager } from '@/theme.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -105,10 +105,10 @@ onMounted(() => { cropper = new Cropper(imgEl.value, { }); - const computedStyle = getComputedStyle(window.document.documentElement); + const themeValue = themeManager.currentCompiledTheme!; const selection = cropper.getCropperSelection()!; - selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); + selection.themeColor = tinycolor(themeValue.accent).toHexString(); if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio; selection.initialAspectRatio = props.aspectRatio ?? 1; selection.outlined = true; diff --git a/packages/frontend/src/components/MkFoldableSection.vue b/packages/frontend/src/components/MkFoldableSection.vue index 0fa7bea7ab..ee152ca4df 100644 --- a/packages/frontend/src/components/MkFoldableSection.vue +++ b/packages/frontend/src/components/MkFoldableSection.vue @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'; import { miLocalStorage } from '@/local-storage.js'; import { prefer } from '@/preferences.js'; -import { globalEvents } from '@/events.js'; +import { themeManager } from '@/theme.js'; import { getBgColor } from '@/utility/get-bg-color.js'; const miLocalStoragePrefix = 'ui:folder:' as const; @@ -92,11 +92,11 @@ function updateBgColor() { onMounted(() => { updateBgColor(); - globalEvents.on('themeChanging', updateBgColor); + themeManager.on('themeChanging', updateBgColor); }); onBeforeUnmount(() => { - globalEvents.off('themeChanging', updateBgColor); + themeManager.off('themeChanging', updateBgColor); }); diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 864f53d09c..1def3de97a 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -100,6 +100,7 @@ import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'; import { prefer } from '@/preferences.js'; import { getBgColor } from '@/utility/get-bg-color.js'; import { pageFolderTeleportCount, popup } from '@/os.js'; +import { themeManager } from '@/theme.js'; import MkFolderPage from '@/components/MkFolderPage.vue'; import { deviceKind } from '@/utility/device-kind.js'; @@ -192,9 +193,9 @@ async function toggle(ev: PointerEvent) { } onMounted(() => { - const computedStyle = getComputedStyle(window.document.documentElement); + const themeValue = themeManager.currentCompiledTheme!; const parentBg = getBgColor(rootEl.value?.parentElement) ?? 'transparent'; - const myBg = computedStyle.getPropertyValue('--MI_THEME-panel'); + const myBg = themeValue.panel; bgSame.value = parentBg === myBg; }); diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 368fa5be27..721e4e88de 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -73,6 +73,7 @@ import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; import { initChart } from '@/utility/init-chart.js'; import { useMkSelect } from '@/composables/use-mkselect.js'; +import { themeManager } from '@/theme.js'; initChart(); @@ -186,7 +187,7 @@ function createDoughnut(chartEl: HTMLCanvasElement, tooltip: ReturnType x.name), datasets: [{ backgroundColor: data.map(x => x.color), - borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), + borderColor: themeManager.currentCompiledTheme!.panel, borderWidth: 2, hoverOffset: 0, data: data.map(x => x.value), diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 582073b878..0275c33f89 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -33,6 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 0bafa1074c..83fadef2fa 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only +
@@ -94,12 +95,14 @@ import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; import XTitlebar from '@/ui/_common_/titlebar.vue'; import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue'; import XReloadSuggestion from '@/ui/_common_/ReloadSuggestion.vue'; +import XThemePreviewing from '@/ui/_common_/ThemePreviewing.vue'; import * as os from '@/os.js'; import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; import { deviceKind } from '@/utility/device-kind.js'; import { prefer } from '@/preferences.js'; import { store } from '@/store.js'; +import { isPreviewMode as isThemePreviewMode } from '@/theme.js'; import XMainColumn from '@/ui/deck/main-column.vue'; import XTlColumn from '@/ui/deck/tl-column.vue'; import XAntennaColumn from '@/ui/deck/antenna-column.vue'; diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 95582edea1..13bf1e5175 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -40,8 +41,10 @@ import type { PageMetadata } from '@/page.js'; import XMobileFooterMenu from '@/ui/_common_/mobile-footer-menu.vue'; import XPreferenceRestore from '@/ui/_common_/PreferenceRestore.vue'; import XReloadSuggestion from '@/ui/_common_/ReloadSuggestion.vue'; +import XThemePreviewing from '@/ui/_common_/ThemePreviewing.vue'; import XTitlebar from '@/ui/_common_/titlebar.vue'; import XSidebar from '@/ui/_common_/navbar.vue'; +import { isPreviewMode as isThemePreviewMode } from '@/theme.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; diff --git a/packages/frontend/src/utility/code-highlighter.ts b/packages/frontend/src/utility/code-highlighter.ts index 4fdaf24202..8c8a54969f 100644 --- a/packages/frontend/src/utility/code-highlighter.ts +++ b/packages/frontend/src/utility/code-highlighter.ts @@ -44,7 +44,7 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise _res.type = mode; if (getName) { - return _res.name; + return _res.name!; } return _res; } diff --git a/packages/frontend/src/utility/init-chart.ts b/packages/frontend/src/utility/init-chart.ts index 260899c1d7..c04fd41573 100644 --- a/packages/frontend/src/utility/init-chart.ts +++ b/packages/frontend/src/utility/init-chart.ts @@ -24,6 +24,7 @@ import { import gradient from 'chartjs-plugin-gradient'; import zoomPlugin from 'chartjs-plugin-zoom'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; +import { themeManager } from '@/theme.js'; import { store } from '@/store.js'; import 'chartjs-adapter-date-fns'; @@ -50,7 +51,7 @@ export function initChart() { ); // フォントカラー - Chart.defaults.color = getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-fg'); + Chart.defaults.color = themeManager.currentCompiledTheme!.fg; Chart.defaults.borderColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; diff --git a/packages/frontend/src/utility/popout.ts b/packages/frontend/src/utility/popout.ts index 7e0222c459..bbea7062c9 100644 --- a/packages/frontend/src/utility/popout.ts +++ b/packages/frontend/src/utility/popout.ts @@ -11,8 +11,9 @@ export function popout(path: string, w?: HTMLElement) { url = appendQuery(url, 'zen'); if (w) { const position = w.getBoundingClientRect(); - const width = parseInt(getComputedStyle(w, '').width, 10); - const height = parseInt(getComputedStyle(w, '').height, 10); + const computedStyle = getComputedStyle(w, ''); + const width = parseInt(computedStyle.width, 10); + const height = parseInt(computedStyle.height, 10); const x = window.screenX + position.left; const y = window.screenY + position.top; window.open(url, url, diff --git a/packages/frontend/src/utility/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts index 74175703c3..8f6789a47d 100644 --- a/packages/frontend/src/utility/theme-editor.ts +++ b/packages/frontend/src/utility/theme-editor.ts @@ -5,8 +5,8 @@ import { genId } from '@/utility/id.js'; -import type { Theme } from '@/theme.js'; -import { themeProps } from '@/theme.js'; +import type { Theme } from '@@/js/theme.js'; +import { themeProps } from '@@/js/theme.js'; export type Default = null; export type Color = string; diff --git a/packages/frontend/test/theme.test.ts b/packages/frontend/test/theme.test.ts new file mode 100644 index 0000000000..c2c71dd1ff --- /dev/null +++ b/packages/frontend/test/theme.test.ts @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, assert, beforeEach, describe, test, vi } from 'vitest'; +import type { Theme } from '@@/js/theme.js'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; +import './init'; + +vi.mock('@/i18n.js', () => ({ + i18n: { + ts: { + _theme: { + alreadyInstalled: 'already installed', + invalid: 'invalid', + }, + }, + }, + updateI18n: vi.fn(), +})); + +vi.mock('@/os.js', () => ({ + alert: vi.fn(), +})); + +const cloneTheme = (value: T): T => structuredClone(value); + +const createTheme = (base: 'light' | 'dark', options: { + id: string; + name: string; + accent: string; + bg: string; + fg: string; +}): Theme => { + const builtin = base === 'dark' ? darkTheme : lightTheme; + + return { + id: options.id, + name: options.name, + author: 'tester', + base, + props: { + ...cloneTheme(builtin.props), + accent: options.accent, + bg: options.bg, + fg: options.fg, + }, + }; +}; + +const primaryTheme = createTheme('light', { + id: 'primary-theme', + name: 'Primary Theme', + accent: '#224488', + bg: '#faf7f2', + fg: '#1a1a1a', +}); + +const previewTheme = createTheme('dark', { + id: 'preview-theme', + name: 'Preview Theme', + accent: '#55aa33', + bg: '#101820', + fg: '#f4f4f4', +}); + +const replacementTheme = createTheme('dark', { + id: 'replacement-theme', + name: 'Replacement Theme', + accent: '#bb5500', + bg: '#18110f', + fg: '#f6e7df', +}); + +const loadThemeModule = async () => { + vi.resetModules(); + return await import('@/theme.js'); +}; + +const resetDocument = () => { + window.localStorage.clear(); + document.head.innerHTML = ''; + document.documentElement.className = ''; + document.documentElement.removeAttribute('data-color-scheme'); + document.documentElement.style.cssText = ''; + Reflect.deleteProperty(document, 'startViewTransition'); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); +}; + +describe('ThemeManager', () => { + beforeEach(() => { + resetDocument(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + test('通常テーマ適用後のプレビューは現在テーマのみを切り替え、キャッシュは保持する', async () => { + const { themeManager, isPreviewMode } = await loadThemeModule(); + + themeManager.updateTheme(primaryTheme); + const cachedTheme = window.localStorage.getItem('theme'); + const cachedThemeId = window.localStorage.getItem('themeId'); + + themeManager.previewTheme(previewTheme); + + assert.strictEqual(themeManager.theme?.id, primaryTheme.id); + assert.strictEqual(themeManager.currentTheme?.id, previewTheme.id); + assert.strictEqual(themeManager.currentThemeId, previewTheme.id); + assert.strictEqual(themeManager.isPreviewMode, true); + assert.strictEqual(isPreviewMode.value, true); + assert.strictEqual(document.documentElement.dataset.colorScheme, 'dark'); + assert.strictEqual(document.documentElement.style.getPropertyValue('--MI_THEME-accent'), themeManager.currentCompiledTheme?.accent); + assert.strictEqual(window.localStorage.getItem('theme'), cachedTheme); + assert.strictEqual(window.localStorage.getItem('themeId'), cachedThemeId); + }); + + test('プレビュー解除で元のテーマと DOM 状態が復元される', async () => { + const { themeManager, isPreviewMode } = await loadThemeModule(); + + themeManager.updateTheme(primaryTheme); + const originalCompiledThemeColor = themeManager.currentCompiledTheme?.htmlThemeColor; + + themeManager.previewTheme(previewTheme); + const previewCompiledThemeColor = themeManager.currentCompiledTheme?.htmlThemeColor; + assert.strictEqual(themeManager.currentTheme?.id, previewTheme.id); + assert.notStrictEqual(previewCompiledThemeColor, originalCompiledThemeColor); + + themeManager.clearPreview(); + + assert.strictEqual(themeManager.theme?.id, primaryTheme.id); + assert.strictEqual(themeManager.currentTheme?.id, primaryTheme.id); + assert.strictEqual(themeManager.currentCompiledTheme?.htmlThemeColor, originalCompiledThemeColor); + assert.strictEqual(themeManager.isPreviewMode, false); + assert.strictEqual(isPreviewMode.value, false); + assert.strictEqual(document.documentElement.dataset.colorScheme, 'light'); + assert.strictEqual(document.documentElement.style.getPropertyValue('--MI_THEME-accent'), themeManager.currentCompiledTheme?.accent); + assert.strictEqual(document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content'), originalCompiledThemeColor); + assert.strictEqual(window.localStorage.getItem('themeId'), primaryTheme.id); + }); + + test('プレビュー中に通常テーマを更新するとプレビューを抜けて新しい通常テーマが適用される', async () => { + const { themeManager, isPreviewMode } = await loadThemeModule(); + + themeManager.updateTheme(primaryTheme); + themeManager.previewTheme(previewTheme); + themeManager.updateTheme(replacementTheme); + + assert.strictEqual(themeManager.theme?.id, replacementTheme.id); + assert.strictEqual(themeManager.currentTheme?.id, replacementTheme.id); + assert.strictEqual(themeManager.isPreviewMode, false); + assert.strictEqual(isPreviewMode.value, false); + assert.strictEqual(document.documentElement.dataset.colorScheme, 'dark'); + assert.strictEqual(document.documentElement.style.getPropertyValue('--MI_THEME-accent'), themeManager.currentCompiledTheme?.accent); + assert.strictEqual(window.localStorage.getItem('themeId'), replacementTheme.id); + }); + + test('themeChanging と themeChanged はプレビュー適用と復帰のたびに発火する', async () => { + const { themeManager } = await loadThemeModule(); + const events: string[] = []; + + themeManager.on('themeChanging', () => { + events.push('themeChanging'); + }); + themeManager.on('themeChanged', () => { + events.push('themeChanged'); + }); + + themeManager.updateTheme(primaryTheme); + themeManager.previewTheme(previewTheme); + themeManager.clearPreview(); + + assert.deepStrictEqual(events, [ + 'themeChanging', + 'themeChanged', + 'themeChanging', + 'themeChanged', + 'themeChanging', + 'themeChanged', + ]); + }); +}); diff --git a/packages/frontend/test/tsconfig.json b/packages/frontend/test/tsconfig.json index 952435e122..12c00b740c 100644 --- a/packages/frontend/test/tsconfig.json +++ b/packages/frontend/test/tsconfig.json @@ -23,7 +23,8 @@ "resolveJsonModule": true, "isolatedModules": true, "paths": { - "@/*": ["../src/*"] + "@/*": ["../src/*"], + "@@/*": ["../../frontend-shared/*"] }, "typeRoots": [ "../node_modules/@types" @@ -37,6 +38,7 @@ "compileOnSave": false, "include": [ "./**/*.ts", - "../src/**/*.vue" + "../src/**/*.vue", + "../@types/**/*.d.ts" ] } diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index 07e9a3afc1..80f62f78fc 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -5651,6 +5651,14 @@ export interface Locale extends ILocale { * リノート先のチャンネルを見る */ "viewRenotedChannel": string; + /** + * テーマのプレビュー中 + */ + "previewingTheme": string; + /** + * 元に戻す + */ + "previewingThemeRestore": string; "_imageEditing": { "_vars": { /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1413aae61..9fabae7389 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1169,9 +1169,18 @@ importers: i18n: specifier: workspace:* version: link:../i18n + json5: + specifier: 2.2.3 + version: 2.2.3 misskey-js: specifier: workspace:* version: link:../misskey-js + shiki: + specifier: 4.0.2 + version: 4.0.2 + tinycolor2: + specifier: 1.6.0 + version: 1.6.0 vue: specifier: 3.5.33 version: 3.5.33(typescript@5.9.3) @@ -1179,21 +1188,18 @@ importers: '@types/node': specifier: 24.12.2 version: 24.12.2 + '@types/tinycolor2': + specifier: 1.4.6 + version: 1.4.6 '@typescript-eslint/eslint-plugin': specifier: 8.59.0 version: 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': specifier: 8.59.0 version: 8.59.0(eslint@9.39.4)(typescript@5.9.3) - esbuild: - specifier: 0.28.0 - version: 0.28.0 eslint-plugin-vue: specifier: 10.9.0 version: 10.9.0(@stylistic/eslint-plugin@5.5.0(eslint@9.39.4))(@typescript-eslint/parser@8.59.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(vue-eslint-parser@10.4.0(eslint@9.39.4)) - nodemon: - specifier: 3.1.14 - version: 3.1.14 vue-eslint-parser: specifier: 10.4.0 version: 10.4.0(eslint@9.39.4) diff --git a/scripts/clean-all.mjs b/scripts/clean-all.mjs index dc750af413..f232be77fd 100644 --- a/scripts/clean-all.mjs +++ b/scripts/clean-all.mjs @@ -13,7 +13,6 @@ const __dirname = import.meta.dirname; fs.rmSync(__dirname + '/../packages/backend/src-js', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/backend/node_modules', { recursive: true, force: true }); - fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend-shared/node_modules', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend-builder/node_modules', { recursive: true, force: true }); diff --git a/scripts/clean.mjs b/scripts/clean.mjs index faa6011ee9..a1f444ad4b 100644 --- a/scripts/clean.mjs +++ b/scripts/clean.mjs @@ -10,7 +10,6 @@ const __dirname = import.meta.dirname; (async () => { fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/backend/src-js', { recursive: true, force: true }); - fs.rmSync(__dirname + '/../packages/frontend-shared/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/frontend-embed/built', { recursive: true, force: true }); fs.rmSync(__dirname + '/../packages/icons-subsetter/built', { recursive: true, force: true }); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index c47a33b7fa..54fba71f0f 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -70,12 +70,6 @@ execa('pnpm', ['--filter', 'backend', 'dev'], { stderr: process.stderr, }); -execa('pnpm', ['--filter', 'frontend-shared', 'watch', '--no-clean'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); - execa('pnpm', ['--filter', 'frontend', 'watch'], { cwd: _dirname + '/../', stdout: process.stdout,