From edbaa0786738fe91a24dd22bb6e1f296792fb72a Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 9 Jan 2021 17:18:45 +0900
Subject: [PATCH] =?UTF-8?q?=E7=B0=A1=E6=98=93=E3=83=86=E3=83=BC=E3=83=9E?=
 =?UTF-8?q?=E3=82=A8=E3=83=87=E3=82=A3=E3=82=BF=E5=AE=9F=E8=A3=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/ja-JP.yml                          |   6 +
 src/client/components/sample.vue           |   4 +-
 src/client/pages/advanced-theme-editor.vue | 353 ++++++++++++++++
 src/client/pages/settings/theme.vue        |  13 +-
 src/client/pages/theme-editor.vue          | 444 ++++++++-------------
 src/client/router.ts                       |   1 +
 src/client/themes/_dark.json5              |   4 +-
 src/client/themes/_light.json5             |   4 +-
 8 files changed, 530 insertions(+), 299 deletions(-)
 create mode 100644 src/client/pages/advanced-theme-editor.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 9bcba51de8..55847cca4e 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -679,6 +679,12 @@ nUsers: "{n}ユーザー"
 nNotes: "{n}ノート"
 sendErrorReports: "エラーリポートを送信"
 sendErrorReportsDescription: "オンにすると、問題が発生したときにエラーの詳細情報がMisskeyに共有され、ソフトウェアの品質向上に役立てることができます。"
+myTheme: "マイテーマ"
+backgroundColor: "背景"
+accentColor: "アクセント"
+textColor: "文字"
+saveAs: "名前を付けて保存"
+advanced: "高度"
 
 _aboutMisskey:
   about: "Misskeyはsyuiloによって2014年から開発されている、オープンソースのソフトウェアです。"
diff --git a/src/client/components/sample.vue b/src/client/components/sample.vue
index b6300ba446..8fd79ceec9 100644
--- a/src/client/components/sample.vue
+++ b/src/client/components/sample.vue
@@ -15,7 +15,7 @@
 		<MkButton inline>This is</MkButton>
 		<MkButton inline primary>the button</MkButton>
 	</div>
-	<div class="_content">
+	<div class="_content" style="pointer-events: none;">
 		<Mfm :text="mfm"/>
 	</div>
 	<div class="_content">
@@ -49,7 +49,7 @@ export default defineComponent({
 	data() {
 		return {
 			text: '',
-			flag: false,
+			flag: true,
 			radio: 'misskey',
 			mfm: `Hello world! This is an @example mention. BTW you are @${this.$i.username}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`
 		}
diff --git a/src/client/pages/advanced-theme-editor.vue b/src/client/pages/advanced-theme-editor.vue
new file mode 100644
index 0000000000..1f5e260379
--- /dev/null
+++ b/src/client/pages/advanced-theme-editor.vue
@@ -0,0 +1,353 @@
+<template>
+<div class="t9makv94">
+	<section class="_section">
+		<div class="_content">
+			<details>
+				<summary>{{ $ts.import }}</summary>
+				<MkTextarea v-model:value="themeToImport">
+					{{ $ts._theme.importInfo }}
+				</MkTextarea>
+				<MkButton :disabled="!themeToImport.trim()" @click="importTheme">{{ $ts.import }}</MkButton>
+			</details>
+		</div>
+	</section>
+	<section class="_section">
+		<div class="_content _card _vMargin">
+			<div class="_content">
+				<MkInput v-model:value="name" required><span>{{ $ts.name }}</span></MkInput>
+				<MkInput v-model:value="author" required><span>{{ $ts.author }}</span></MkInput>
+				<MkTextarea v-model:value="description"><span>{{ $ts.description }}</span></MkTextarea>
+				<div class="_inputs">
+					<div v-text="$ts._theme.base" />
+					<MkRadio v-model="baseTheme" value="light">{{ $ts.light }}</MkRadio>
+					<MkRadio v-model="baseTheme" value="dark">{{ $ts.dark }}</MkRadio>
+				</div>
+			</div>
+		</div>
+		<div class="_content _card _vMargin">
+			<div class="list-view _content">
+				<div class="item" v-for="([ k, v ], i) in theme" :key="k">
+					<div class="_inputs">
+						<div>
+							{{ k.startsWith('$') ? `${k} (${$ts._theme.constant})` : $t('_theme.keys.' + k) }}
+							<button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$ts.delete" />
+						</div>
+						<div>
+							<div class="type" @click="chooseType($event, i)">
+								{{ getTypeOf(v) }} <Fa :icon="faChevronDown"/>
+							</div>
+							<!-- default -->
+							<div v-if="v === null" v-text="baseProps[k]" class="default-value" />
+							<!-- color -->
+							<div v-else-if="typeof v === 'string'" class="color">
+								<input type="color" :value="v" @input="colorChanged($event.target.value, i)"/>
+								<MkInput class="select" :value="v" @update:value="colorChanged($event, i)"/>
+							</div>
+							<!-- ref const -->
+							<MkInput v-else-if="v.type === 'refConst'" v-model:value="v.key">
+								<template #prefix>$</template>
+								<span>{{ $ts.name }}</span>
+							</MkInput>
+							<!-- ref props -->
+							<MkSelect class="select" v-else-if="v.type === 'refProp'" v-model:value="v.key">
+								<option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+							</MkSelect>
+							<!-- func -->
+							<template v-else-if="v.type === 'func'">
+								<MkSelect class="select" v-model:value="v.name">
+									<template #label>{{ $ts._theme.funcKind }}</template>
+									<option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option>
+								</MkSelect>
+								<MkInput type="number" v-model:value="v.arg"><span>{{ $ts._theme.argument }}</span></MkInput>
+								<MkSelect class="select" v-model:value="v.value">
+									<template #label>{{ $ts._theme.basedProp }}</template>
+									<option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+								</MkSelect>
+							</template>
+							<!-- CSS -->
+							<MkInput v-else-if="v.type === 'css'" v-model:value="v.value">
+								<span>CSS</span>
+							</MkInput>
+						</div>
+					</div>
+				</div>
+				<MkButton primary @click="addConst">{{ $ts._theme.addConstant }}</MkButton>
+			</div>
+		</div>
+	</section>
+	<section class="_section">
+		<details class="_content">
+			<summary>{{ $ts.sample }}</summary>
+			<MkSample/>
+		</details>
+	</section>
+	<section class="_section">
+		<div class="_content">
+			<MkButton inline @click="preview">{{ $ts.preview }}</MkButton>
+			<MkButton inline primary :disabled="!name || !author" @click="save">{{ $ts.save }}</MkButton>
+		</div>
+	</section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faPalette, faChevronDown, faKeyboard } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import { toUnicode } from 'punycode';
+
+import MkRadio from '@/components/ui/radio.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/ui/input.vue';
+import MkTextarea from '@/components/ui/textarea.vue';
+import MkSelect from '@/components/ui/select.vue';
+import MkSample from '@/components/sample.vue';
+
+import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@/scripts/theme-editor';
+import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@/scripts/theme';
+import { host } from '@/config';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+	components: {
+		MkRadio,
+		MkButton,
+		MkInput,
+		MkTextarea,
+		MkSelect,
+		MkSample,
+	},
+
+	data() {
+		return {
+			INFO: {
+				title: this.$ts.themeEditor,
+				icon: faPalette,
+			},
+			theme: [] as ThemeViewModel,
+			name: '',
+			description: '',
+			baseTheme: 'light' as 'dark' | 'light',
+			author: `@${this.$i.username}@${toUnicode(host)}`,
+			themeToImport: '',
+			changed: false,
+			lightTheme, darkTheme, themeProps,
+			faPalette, faChevronDown, faKeyboard,
+		}
+	},
+
+	computed: {
+		baseProps() {
+			return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props;
+		},
+	},
+
+	beforeUnmount() {
+		window.removeEventListener('beforeunload', this.beforeunload);
+	},
+
+	async beforeRouteLeave(to, from, next) {
+		if (this.changed && !(await this.confirm())) {
+			next(false);
+		} else {
+			next();
+		}
+	},
+
+	mounted() {
+		this.init();
+		window.addEventListener('beforeunload', this.beforeunload);
+		const changed = () => this.changed = true;
+		this.$watch('name', changed);
+		this.$watch('description', changed);
+		this.$watch('baseTheme', changed);
+		this.$watch('author', changed);
+		this.$watch('theme', changed);
+	},
+
+	methods: {
+		beforeunload(e: BeforeUnloadEvent) {
+			if (this.changed) {
+				e.preventDefault();
+				e.returnValue = '';
+			}
+		},
+
+		async confirm(): Promise<boolean> {
+			const { canceled } = await os.dialog({
+				type: 'warning',
+				text: this.$ts.leaveConfirm,
+				showCancelButton: true
+			});
+			return !canceled;
+		},
+
+		init() {
+			const t: ThemeViewModel = [];
+			for (const key of themeProps) {
+				t.push([ key, null ]);
+			}
+			this.theme = t;
+		},
+	
+		async del(i: number) {
+			const { canceled } = await os.dialog({ 
+				type: 'warning',
+				showCancelButton: true,
+				text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }),
+			});
+			if (canceled) return;
+			Vue.delete(this.theme, i);
+		},
+	
+		async addConst() {
+			const { canceled, result } = await os.dialog({
+				title: this.$ts._theme.inputConstantName,
+				input: true
+			});
+			if (canceled) return;
+			this.theme.push([ '$' + result, '#000000']);
+		},
+	
+		save() {
+			const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
+			const themes = ColdDeviceStorage.get('themes').concat(theme);
+			ColdDeviceStorage.set('themes', themes);
+			os.dialog({
+				type: 'success',
+				text: this.$t('_theme.installed', { name: theme.name })
+			});
+			this.changed = false;
+		},
+	
+		preview() {
+			const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
+			try {
+				applyTheme(theme, false);
+			} catch (e) {
+				os.dialog({
+					type: 'error',
+					text: e.message
+				});
+			}
+		},
+	
+		async importTheme() {
+			if (this.changed && (!await this.confirm())) return;
+
+			try {
+				const theme = JSON5.parse(this.themeToImport) as Theme;
+				if (!validateTheme(theme)) throw new Error(this.$ts._theme.invalid);
+
+				this.name = theme.name;
+				this.description = theme.desc || '';
+				this.author = theme.author;
+				this.baseTheme = theme.base || 'light';
+				this.theme = convertToViewModel(theme);
+				this.themeToImport = '';
+			} catch (e) {
+				os.dialog({
+					type: 'error',
+					text: e.message
+				});
+			}
+		},
+	
+		colorChanged(color: string, i: number) {
+			this.theme[i] = [this.theme[i][0], color];
+		},
+
+		getTypeOf(v: ThemeValue) {
+			return v === null
+				? this.$ts._theme.defaultValue
+				: typeof v === 'string'
+					? this.$ts._theme.color
+					: this.$t('_theme.' + v.type);
+		},
+	
+		async chooseType(e: MouseEvent, i: number) {
+			const newValue = await this.showTypeMenu(e);
+			this.theme[i] = [ this.theme[i][0], newValue ];
+		},
+	
+		showTypeMenu(e: MouseEvent) {
+			return new Promise<ThemeValue>((resolve) => {
+				os.modalMenu([{
+					text: this.$ts._theme.defaultValue,
+					action: () => resolve(null),
+				}, {
+					text: this.$ts._theme.color,
+					action: () => resolve('#000000'),
+				}, {
+					text: this.$ts._theme.func,
+					action: () => resolve({
+						type: 'func', name: 'alpha', arg: 1, value: 'accent'
+					}),
+				}, {
+					text: this.$ts._theme.refProp,
+					action: () => resolve({
+						type: 'refProp', key: 'accent',
+					}),
+				}, {
+					text: this.$ts._theme.refConst,
+					action: () => resolve({
+						type: 'refConst', key: '',
+					}),
+				}, {
+					text: 'CSS',
+					action: () => resolve({
+						type: 'css', value: '',
+					}),
+				}], e.currentTarget || e.target);
+			});
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.t9makv94 {
+	> ._section {
+		> ._content {
+			> .list-view {
+				> .item {
+					min-height: 48px;
+					word-break: break-all;
+
+					&:not(:last-child) {
+						margin-bottom: 8px;
+					}
+
+					.select {
+						margin: 24px 0;
+					}
+
+					.type {
+						cursor: pointer;
+					}
+
+					.default-value {
+						opacity: 0.6;
+						pointer-events: none;
+						user-select: none;
+					}
+
+					.color {
+						> input {
+							display: inline-block;
+							width: 1.5em;
+							height: 1.5em;
+						}
+
+						> div {
+							margin-left: 8px;
+							display: inline-block;
+						}
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue
index dd7911ce34..da1ad618b5 100644
--- a/src/client/pages/settings/theme.vue
+++ b/src/client/pages/settings/theme.vue
@@ -49,11 +49,14 @@
 	<FormButton primary v-else @click="wallpaper = null">{{ $ts.removeWallpaper }}</FormButton>
 
 	<FormGroup>
-		<FormLink to="https://assets.msky.cafe/theme/list" external>{{ $ts._theme.explore }}</FormLink>
-		<FormLink to="/theme-editor">{{ $ts._theme.make }}</FormLink>
+		<FormLink to="https://assets.msky.cafe/theme/list" external><template #icon><Fa :icon="faGlobe"/></template>{{ $ts._theme.explore }}</FormLink>
+		<FormLink to="/settings/theme/install"><template #icon><Fa :icon="faDownload"/></template>{{ $ts._theme.install }}</FormLink>
 	</FormGroup>
 
-	<FormLink to="/settings/theme/install"><template #icon><Fa :icon="faDownload"/></template>{{ $ts._theme.install }}</FormLink>
+	<FormGroup>
+		<FormLink to="/theme-editor"><template #icon><Fa :icon="faPaintRoller"/></template>{{ $ts._theme.make }}</FormLink>
+		<FormLink to="/advanced-theme-editor"><template #icon><Fa :icon="faPaintRoller"/></template>{{ $ts._theme.make }} ({{ $ts.advanced }})</FormLink>
+	</FormGroup>
 
 	<FormLink to="/settings/theme/manage"><template #icon><Fa :icon="faFolderOpen"/></template>{{ $ts._theme.manage }}</FormLink>
 </FormBase>
@@ -61,7 +64,7 @@
 
 <script lang="ts">
 import { computed, defineComponent, onMounted, ref, watch } from 'vue';
-import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
+import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye, faGlobe, faPaintRoller } from '@fortawesome/free-solid-svg-icons';
 import FormSwitch from '@/components/form/switch.vue';
 import FormSelect from '@/components/form/select.vue';
 import FormBase from '@/components/form/base.vue';
@@ -148,7 +151,7 @@ export default defineComponent({
 					wallpaper.value = file.url;
 				});
 			},
-			faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye
+			faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye, faGlobe, faPaintRoller,
 		};
 	}
 });
diff --git a/src/client/pages/theme-editor.vue b/src/client/pages/theme-editor.vue
index 1f5e260379..6ee0c11bdb 100644
--- a/src/client/pages/theme-editor.vue
+++ b/src/client/pages/theme-editor.vue
@@ -1,121 +1,59 @@
 <template>
-<div class="t9makv94">
-	<section class="_section">
-		<div class="_content">
-			<details>
-				<summary>{{ $ts.import }}</summary>
-				<MkTextarea v-model:value="themeToImport">
-					{{ $ts._theme.importInfo }}
-				</MkTextarea>
-				<MkButton :disabled="!themeToImport.trim()" @click="importTheme">{{ $ts.import }}</MkButton>
-			</details>
+<FormBase class="cwepdizn">
+	<div class="_formItem colorPicker">
+		<div class="_formLabel">{{ $ts.backgroundColor }}</div>
+		<div class="_formPanel colors">
+			<button v-for="color in bgColors" :key="color.color" @click="bgColor = color" class="color _button" :class="{ active: bgColor.color === color.color }">
+				<div class="preview" :style="{ background: color.forPreview }"></div>
+			</button>
 		</div>
-	</section>
-	<section class="_section">
-		<div class="_content _card _vMargin">
-			<div class="_content">
-				<MkInput v-model:value="name" required><span>{{ $ts.name }}</span></MkInput>
-				<MkInput v-model:value="author" required><span>{{ $ts.author }}</span></MkInput>
-				<MkTextarea v-model:value="description"><span>{{ $ts.description }}</span></MkTextarea>
-				<div class="_inputs">
-					<div v-text="$ts._theme.base" />
-					<MkRadio v-model="baseTheme" value="light">{{ $ts.light }}</MkRadio>
-					<MkRadio v-model="baseTheme" value="dark">{{ $ts.dark }}</MkRadio>
-				</div>
-			</div>
+	</div>
+	<div class="_formItem colorPicker">
+		<div class="_formLabel">{{ $ts.accentColor }}</div>
+		<div class="_formPanel colors">
+			<button v-for="color in accentColors" :key="color" @click="accentColor = color" class="color rounded _button" :class="{ active: accentColor === color }">
+				<div class="preview" :style="{ background: color }"></div>
+			</button>
 		</div>
-		<div class="_content _card _vMargin">
-			<div class="list-view _content">
-				<div class="item" v-for="([ k, v ], i) in theme" :key="k">
-					<div class="_inputs">
-						<div>
-							{{ k.startsWith('$') ? `${k} (${$ts._theme.constant})` : $t('_theme.keys.' + k) }}
-							<button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$ts.delete" />
-						</div>
-						<div>
-							<div class="type" @click="chooseType($event, i)">
-								{{ getTypeOf(v) }} <Fa :icon="faChevronDown"/>
-							</div>
-							<!-- default -->
-							<div v-if="v === null" v-text="baseProps[k]" class="default-value" />
-							<!-- color -->
-							<div v-else-if="typeof v === 'string'" class="color">
-								<input type="color" :value="v" @input="colorChanged($event.target.value, i)"/>
-								<MkInput class="select" :value="v" @update:value="colorChanged($event, i)"/>
-							</div>
-							<!-- ref const -->
-							<MkInput v-else-if="v.type === 'refConst'" v-model:value="v.key">
-								<template #prefix>$</template>
-								<span>{{ $ts.name }}</span>
-							</MkInput>
-							<!-- ref props -->
-							<MkSelect class="select" v-else-if="v.type === 'refProp'" v-model:value="v.key">
-								<option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
-							</MkSelect>
-							<!-- func -->
-							<template v-else-if="v.type === 'func'">
-								<MkSelect class="select" v-model:value="v.name">
-									<template #label>{{ $ts._theme.funcKind }}</template>
-									<option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option>
-								</MkSelect>
-								<MkInput type="number" v-model:value="v.arg"><span>{{ $ts._theme.argument }}</span></MkInput>
-								<MkSelect class="select" v-model:value="v.value">
-									<template #label>{{ $ts._theme.basedProp }}</template>
-									<option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
-								</MkSelect>
-							</template>
-							<!-- CSS -->
-							<MkInput v-else-if="v.type === 'css'" v-model:value="v.value">
-								<span>CSS</span>
-							</MkInput>
-						</div>
-					</div>
-				</div>
-				<MkButton primary @click="addConst">{{ $ts._theme.addConstant }}</MkButton>
-			</div>
+	</div>
+	<div class="_formItem colorPicker">
+		<div class="_formLabel">{{ $ts.textColor }}</div>
+		<div class="_formPanel colors">
+			<button v-for="color in fgColors" :key="color" @click="fgColor = color" class="color char _button" :class="{ active: fgColor === color }">
+				<div class="preview" :style="{ color: color.forPreview ? color.forPreview : bgColor.kind === 'light' ? '#5f5f5f' : '#dadada' }">A</div>
+			</button>
 		</div>
-	</section>
-	<section class="_section">
-		<details class="_content">
-			<summary>{{ $ts.sample }}</summary>
-			<MkSample/>
-		</details>
-	</section>
-	<section class="_section">
-		<div class="_content">
-			<MkButton inline @click="preview">{{ $ts.preview }}</MkButton>
-			<MkButton inline primary :disabled="!name || !author" @click="save">{{ $ts.save }}</MkButton>
+	</div>
+	<div class="_formItem preview">
+		<div class="_formLabel">{{ $ts.preview }}</div>
+		<div class="_formPanel preview">
+			<MkSample class="preview"/>
 		</div>
-	</section>
-</div>
+	</div>
+	<FormButton @click="saveAs">{{ $ts.saveAs }}</FormButton>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faPalette, faChevronDown, faKeyboard } from '@fortawesome/free-solid-svg-icons';
-import * as JSON5 from 'json5';
 import { toUnicode } from 'punycode';
+import * as tinycolor from 'tinycolor2';
+import { v4 as uuid} from 'uuid';
 
-import MkRadio from '@/components/ui/radio.vue';
-import MkButton from '@/components/ui/button.vue';
-import MkInput from '@/components/ui/input.vue';
-import MkTextarea from '@/components/ui/textarea.vue';
-import MkSelect from '@/components/ui/select.vue';
+import FormBase from '@/components/form/base.vue';
+import FormButton from '@/components/form/button.vue';
 import MkSample from '@/components/sample.vue';
 
-import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@/scripts/theme-editor';
-import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@/scripts/theme';
+import { Theme, applyTheme, validateTheme } from '@/scripts/theme';
 import { host } from '@/config';
 import * as os from '@/os';
 import { ColdDeviceStorage } from '@/store';
 
 export default defineComponent({
 	components: {
-		MkRadio,
-		MkButton,
-		MkInput,
-		MkTextarea,
-		MkSelect,
+		FormBase,
+		FormButton,
 		MkSample,
 	},
 
@@ -125,229 +63,159 @@ export default defineComponent({
 				title: this.$ts.themeEditor,
 				icon: faPalette,
 			},
-			theme: [] as ThemeViewModel,
-			name: '',
-			description: '',
-			baseTheme: 'light' as 'dark' | 'light',
-			author: `@${this.$i.username}@${toUnicode(host)}`,
-			themeToImport: '',
-			changed: false,
-			lightTheme, darkTheme, themeProps,
-			faPalette, faChevronDown, faKeyboard,
+			bgColors: [
+				{ color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' },
+				{ color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' },
+				{ color: '#e9eff0', kind: 'light', forPreview: '#bfe3e8' },
+				{ color: '#f0e9ee', kind: 'light', forPreview: '#f1d1e8' },
+				{ color: '#2b2b2b', kind: 'dark', forPreview: '#2b2b2b' },
+				{ color: '#362e29', kind: 'dark', forPreview: '#735c4d' },
+				{ color: '#303629', kind: 'dark', forPreview: '#506d2f' },
+				{ color: '#293436', kind: 'dark', forPreview: '#258192' },
+			],
+			bgColor: null,
+			accentColors: ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83'],
+			accentColor: null,
+			fgColors: [
+				{ color: 'none', forLight: '#5f5f5f', forDark: '#dadada', forPreview: null },
+				{ color: 'red', forLight: '#7f6666', forDark: '#e4d1d1', forPreview: '#ca4343' },
+				{ color: 'yellow', forLight: '#736955', forDark: '#e0d5c0', forPreview: '#d49923' },
+				{ color: 'green', forLight: '#586d5b', forDark: '#d1e4d4', forPreview: '#4cbd5c' },
+				{ color: 'cyan', forLight: '#5d7475', forDark: '#d1e3e4', forPreview: '#2abdc3' },
+				{ color: 'blue', forLight: '#676880', forDark: '#d1d2e4', forPreview: '#3035b5' },
+				{ color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' },
+			],
+			fgColor: null,
+			faPalette,
 		}
 	},
 
-	computed: {
-		baseProps() {
-			return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props;
-		},
-	},
+	created() {
+		const currentBgColor = getComputedStyle(document.documentElement).getPropertyValue('--bg');
+		const matchedBgColor = this.bgColors.find(x => tinycolor(x.color).toRgbString() === tinycolor(currentBgColor).toRgbString());
+		this.bgColor = matchedBgColor ? matchedBgColor : this.bgColors[0];
+		const currentAccentColor = getComputedStyle(document.documentElement).getPropertyValue('--accent');
+		const matchedAccentColor = this.accentColors.find(x => tinycolor(x).toRgbString() === tinycolor(currentAccentColor).toRgbString());
+		this.accentColor = matchedAccentColor ? matchedAccentColor : this.accentColors[0];
+		const currentFgColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+		const matchedFgColor = this.fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(currentFgColor).toRgbString()));
+		this.fgColor = matchedFgColor ? matchedFgColor : this.fgColors[0];
 
-	beforeUnmount() {
-		window.removeEventListener('beforeunload', this.beforeunload);
-	},
-
-	async beforeRouteLeave(to, from, next) {
-		if (this.changed && !(await this.confirm())) {
-			next(false);
-		} else {
-			next();
-		}
-	},
-
-	mounted() {
-		this.init();
-		window.addEventListener('beforeunload', this.beforeunload);
-		const changed = () => this.changed = true;
-		this.$watch('name', changed);
-		this.$watch('description', changed);
-		this.$watch('baseTheme', changed);
-		this.$watch('author', changed);
-		this.$watch('theme', changed);
+		this.$watch('bgColor', this.apply);
+		this.$watch('accentColor', this.apply);
+		this.$watch('fgColor', this.apply);
+		this.apply();
 	},
 
 	methods: {
-		beforeunload(e: BeforeUnloadEvent) {
-			if (this.changed) {
-				e.preventDefault();
-				e.returnValue = '';
-			}
+		convert() {
+			return {
+				id: '#MY_THEME#',
+				name: this.$ts.myTheme,
+				base: this.bgColor.kind,
+				props: {
+					bg: this.bgColor.color,
+					fg: this.bgColor.kind === 'light' ? this.fgColor.forLight : this.fgColor.forDark,
+					accent: this.accentColor,
+				}
+			};
 		},
 
-		async confirm(): Promise<boolean> {
-			const { canceled } = await os.dialog({
-				type: 'warning',
-				text: this.$ts.leaveConfirm,
-				showCancelButton: true
-			});
-			return !canceled;
+		apply() {
+			const theme = this.convert();
+			applyTheme(theme, true);
+
+			const themes = ColdDeviceStorage.get('themes').filter(t => t.id != '#MY_THEME#').concat(theme);
+			ColdDeviceStorage.set('themes', themes);
+			ColdDeviceStorage.set('lightTheme', theme.id);
+			ColdDeviceStorage.set('darkTheme', theme.id);
 		},
 
-		init() {
-			const t: ThemeViewModel = [];
-			for (const key of themeProps) {
-				t.push([ key, null ]);
-			}
-			this.theme = t;
-		},
-	
-		async del(i: number) {
-			const { canceled } = await os.dialog({ 
-				type: 'warning',
-				showCancelButton: true,
-				text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }),
+		async saveAs() {
+			const { canceled, result: name } = await os.dialog({
+				title: this.$ts.name,
+				input: {
+					allowEmpty: false
+				}
 			});
 			if (canceled) return;
-			Vue.delete(this.theme, i);
-		},
-	
-		async addConst() {
-			const { canceled, result } = await os.dialog({
-				title: this.$ts._theme.inputConstantName,
-				input: true
-			});
-			if (canceled) return;
-			this.theme.push([ '$' + result, '#000000']);
-		},
-	
-		save() {
-			const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
+
+			const theme = this.convert();
+			theme.id = uuid();
+			theme.name = name;
+			theme.author = `@${this.$i.username}@${toUnicode(host)}`;
 			const themes = ColdDeviceStorage.get('themes').concat(theme);
 			ColdDeviceStorage.set('themes', themes);
+			ColdDeviceStorage.set('lightTheme', theme.id);
+			ColdDeviceStorage.set('darkTheme', theme.id);
 			os.dialog({
 				type: 'success',
 				text: this.$t('_theme.installed', { name: theme.name })
 			});
-			this.changed = false;
-		},
-	
-		preview() {
-			const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
-			try {
-				applyTheme(theme, false);
-			} catch (e) {
-				os.dialog({
-					type: 'error',
-					text: e.message
-				});
-			}
-		},
-	
-		async importTheme() {
-			if (this.changed && (!await this.confirm())) return;
-
-			try {
-				const theme = JSON5.parse(this.themeToImport) as Theme;
-				if (!validateTheme(theme)) throw new Error(this.$ts._theme.invalid);
-
-				this.name = theme.name;
-				this.description = theme.desc || '';
-				this.author = theme.author;
-				this.baseTheme = theme.base || 'light';
-				this.theme = convertToViewModel(theme);
-				this.themeToImport = '';
-			} catch (e) {
-				os.dialog({
-					type: 'error',
-					text: e.message
-				});
-			}
-		},
-	
-		colorChanged(color: string, i: number) {
-			this.theme[i] = [this.theme[i][0], color];
-		},
-
-		getTypeOf(v: ThemeValue) {
-			return v === null
-				? this.$ts._theme.defaultValue
-				: typeof v === 'string'
-					? this.$ts._theme.color
-					: this.$t('_theme.' + v.type);
-		},
-	
-		async chooseType(e: MouseEvent, i: number) {
-			const newValue = await this.showTypeMenu(e);
-			this.theme[i] = [ this.theme[i][0], newValue ];
-		},
-	
-		showTypeMenu(e: MouseEvent) {
-			return new Promise<ThemeValue>((resolve) => {
-				os.modalMenu([{
-					text: this.$ts._theme.defaultValue,
-					action: () => resolve(null),
-				}, {
-					text: this.$ts._theme.color,
-					action: () => resolve('#000000'),
-				}, {
-					text: this.$ts._theme.func,
-					action: () => resolve({
-						type: 'func', name: 'alpha', arg: 1, value: 'accent'
-					}),
-				}, {
-					text: this.$ts._theme.refProp,
-					action: () => resolve({
-						type: 'refProp', key: 'accent',
-					}),
-				}, {
-					text: this.$ts._theme.refConst,
-					action: () => resolve({
-						type: 'refConst', key: '',
-					}),
-				}, {
-					text: 'CSS',
-					action: () => resolve({
-						type: 'css', value: '',
-					}),
-				}], e.currentTarget || e.target);
-			});
 		}
 	}
 });
 </script>
 
 <style lang="scss" scoped>
-.t9makv94 {
-	> ._section {
-		> ._content {
-			> .list-view {
-				> .item {
-					min-height: 48px;
-					word-break: break-all;
+.cwepdizn {
+	max-width: 800px;
+	margin: 0 auto;
 
-					&:not(:last-child) {
-						margin-bottom: 8px;
+	> .colorPicker {
+		> .colors {
+			padding: 32px;
+			text-align: center;
+
+			> .color {
+				display: inline-block;
+				position: relative;
+				width: 64px;
+				height: 64px;
+				border-radius: 8px;
+
+				&:hover {
+					> .preview {
+						transform: scale(1.1);
 					}
+				}
 
-					.select {
-						margin: 24px 0;
+				> .preview {
+					position: absolute;
+					top: 0;
+					left: 0;
+					right: 0;
+					bottom: 0;
+					margin: auto;
+					width: 42px;
+					height: 42px;
+					border-radius: 4px;
+					box-shadow: 0 2px 4px rgb(0 0 0 / 30%);
+					transition: transform 0.15s ease;
+				}
+
+				&.active {
+					box-shadow: 0 0 0 2px var(--divider) inset;
+				}
+
+				&.rounded {
+					border-radius: 999px;
+
+					> .preview {
+						border-radius: 999px;
 					}
+				}
 
-					.type {
-						cursor: pointer;
-					}
-
-					.default-value {
-						opacity: 0.6;
-						pointer-events: none;
-						user-select: none;
-					}
-
-					.color {
-						> input {
-							display: inline-block;
-							width: 1.5em;
-							height: 1.5em;
-						}
-
-						> div {
-							margin-left: 8px;
-							display: inline-block;
-						}
-					}
+				&.char {
+					line-height: 42px;
 				}
 			}
 		}
 	}
+
+	> .preview > .preview > .preview {
+		box-shadow: none;
+		background: transparent;
+	}
 }
 </style>
diff --git a/src/client/router.ts b/src/client/router.ts
index 2826f4ac14..5753a47024 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -29,6 +29,7 @@ export const router = createRouter({
 		{ path: '/featured', component: page('featured') },
 		{ path: '/docs', component: page('docs') },
 		{ path: '/theme-editor', component: page('theme-editor') },
+		{ path: '/advanced-theme-editor', component: page('advanced-theme-editor') },
 		{ path: '/docs/:doc', component: page('doc'), props: route => ({ doc: route.params.doc }) },
 		{ path: '/explore', component: page('explore') },
 		{ path: '/explore/tags/:tag', props: true, component: page('explore') },
diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5
index 18075ac322..847c0b4ec4 100644
--- a/src/client/themes/_dark.json5
+++ b/src/client/themes/_dark.json5
@@ -15,11 +15,11 @@
 		focus: ':alpha<0.3<@accent',
 		bg: '#000',
 		acrylicBg: ':alpha<0.5<@bg',
-		fg: '#c7d1d8',
+		fg: '#dadada',
 		fgHighlighted: ':lighten<3<@fg',
 		divider: 'rgba(255, 255, 255, 0.1)',
 		indicator: '@accent',
-		panel: '#000',
+		panel: ':lighten<3<@bg',
 		panelHighlight: ':lighten<3<@panel',
 		panelHeaderBg: ':lighten<3<@panel',
 		panelHeaderFg: '@fg',
diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5
index 2b9bbdd5fd..d75e94afd6 100644
--- a/src/client/themes/_light.json5
+++ b/src/client/themes/_light.json5
@@ -15,11 +15,11 @@
 		focus: ':alpha<0.3<@accent',
 		bg: '#fff',
 		acrylicBg: ':alpha<0.5<@bg',
-		fg: '#5c6a73',
+		fg: '#5f5f5f',
 		fgHighlighted: ':darken<3<@fg',
 		divider: 'rgba(0, 0, 0, 0.1)',
 		indicator: '@accent',
-		panel: '#fff',
+		panel: ':lighten<3<@bg',
 		panelHighlight: ':darken<3<@panel',
 		panelHeaderBg: ':lighten<3<@panel',
 		panelHeaderFg: '@fg',