diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index ab4b10549c..8ae0628471 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -519,6 +519,10 @@ fixedWidgetsPosition: "ウィジェットの位置を固定する"
 enablePlayer: "プレイヤーを開く"
 disablePlayer: "プレイヤーを閉じる"
 expandTweet: "ツイートを展開する"
+themeEditor: "テーマエディター"
+description: "説明"
+author: "作者"
+leaveConfirm: "未保存の変更があります。破棄しますか?"
 deck: "デッキ"
 undeck: "デッキ解除"
 
@@ -530,6 +534,70 @@ _theme:
   installed: "{name}をインストールしました"
   alreadyInstalled: "そのテーマは既にインストールされています"
   invalid: "テーマの形式が間違っています"
+  make: "テーマを作る"
+  base: "ベース"
+  addConstant: "定数を追加"
+  constant: "定数"
+  defaultValue: "デフォルト値"
+  color: "色"
+  refProp: "プロパティを参照"
+  refConst: "定数を参照"
+  key: "キー"
+  func: "関数"
+  funcKind: "関数の種類"
+  argument: "引数"
+  basedProp: "元にするプロパティの名前"
+  alpha: "不透明度"
+  darken: "暗さ"
+  lighten: "明るさ"
+  inputConstantName: "定数名を入力してください"
+  importInfo: "ここにテーマコードを貼り付けて、エディターにインポートできます"
+  deleteConstantConfirm: "定数 {const} を削除しても良いですか?"
+
+  keys:
+    accent: "アクセント"
+    bg: "背景"
+    fg: "文字"
+    focus: "フォーカス"
+    indicator: "インジケーター"
+    panel: "パネル"
+    shadow: "影"
+    header: "ヘッダー"
+    navBg: "サイドバーの背景"
+    navFg: "サイドバーの文字"
+    navHoverFg: "サイドバー文字(ホバー)"
+    navActive: "サイドバー文字(アクティブ)"
+    navIndicator: "サイドバーのインジケーター"
+    link: "リンク"
+    hashtag: "ハッシュタグ"
+    mention: "メンション"
+    mentionMe: "あなた宛てメンション"
+    renote: "Renote"
+    modalBg: "モーダルの背景"
+    divider: "分割線"
+    scrollbarHandle: "スクロールバーの取っ手"
+    scrollbarHandleHover: "スクロールバーの取っ手(ホバー)"
+    dateLabelFg: "日付ラベルの文字"
+    infoBg: "情報の背景"
+    infoFg: "情報の文字"
+    infoWarnBg: "警告の背景"
+    infoWarnFg: "警告の文字"
+    cwBg: "CW ボタンの背景"
+    cwFg: "CW ボタンの文字"
+    cwHoverBg: "CW ボタンの背景 (ホバー)"
+    toastBg: "通知トーストの背景"
+    toastFg: "通知トーストの文字"
+    buttonBg: "ボタンの背景"
+    buttonHoverBg: "ボタンの背景 (ホバー)"
+    inputBorder: "入力ボックスの縁取り"
+    listItemHoverBg: "リスト項目の背景 (ホバー)"
+    driveFolderBg: "ドライブフォルダーの背景"
+    wallpaperOverlay: "壁紙のオーバーレイ"
+    badge: "バッジ"
+    messageBg: "チャットの背景"
+    accentDarken: "アクセント (暗め)"
+    accentLighten: "アクセント (明るめ)"
+    fgHighlighted: "強調された文字"
 
 _sfx:
   note: "ノート"
diff --git a/src/client/app.vue b/src/client/app.vue
index 4f39183564..a751d5db44 100644
--- a/src/client/app.vue
+++ b/src/client/app.vue
@@ -131,6 +131,10 @@ export default Vue.extend({
 	computed: {
 		keymap(): any {
 			return {
+				'd': () => {
+					if (this.$store.state.device.syncDeviceDarkMode) return;
+					this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode });
+				},
 				'p': this.post,
 				'n': this.post,
 				's': this.search,
diff --git a/src/client/pages/preferences/theme.vue b/src/client/pages/preferences/theme.vue
index 246787fa58..173ccd7091 100644
--- a/src/client/pages/preferences/theme.vue
+++ b/src/client/pages/preferences/theme.vue
@@ -22,6 +22,7 @@
 				</label>
 			</div>
 		</div>
+		<mk-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch>
 	</div>
 	<div class="_content">
 		<mk-select v-model="lightTheme">
@@ -42,10 +43,7 @@
 				<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
 			</optgroup>
 		</mk-select>
-		<a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>
-	</div>
-	<div class="_content">
-		<mk-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch>
+		<a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<router-link to="/theme-editor" class="_link">{{ $t('_theme.make') }}</router-link>
 	</div>
 	<div class="_content">
 		<mk-button primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</mk-button>
diff --git a/src/client/pages/room/room.vue b/src/client/pages/room/room.vue
index cf6850526f..05b93c04e8 100644
--- a/src/client/pages/room/room.vue
+++ b/src/client/pages/room/room.vue
@@ -143,7 +143,7 @@ export default Vue.extend({
 		if (this.changed) {
 			this.$root.dialog({
 				type: 'warning',
-				text: this.$t('leave-confirm'),
+				text: this.$t('leaveConfirm'),
 				showCancelButton: true
 			}).then(({ canceled }) => {
 				if (canceled) {
diff --git a/src/client/pages/theme-editor.vue b/src/client/pages/theme-editor.vue
new file mode 100644
index 0000000000..3a3fbfa2d7
--- /dev/null
+++ b/src/client/pages/theme-editor.vue
@@ -0,0 +1,343 @@
+<template>
+<div class="t9makv94">
+	<portal to="icon"><fa :icon="faPalette"/></portal>
+	<portal to="title">{{ $t('themeEditor') }}</portal>
+
+	<section class="_card">
+		<div class="_content">
+			<mk-input v-model="name" required><span>{{ $t('name') }}</span></mk-input>
+			<mk-input v-model="author" required><span>{{ $t('author') }}</span></mk-input>
+			<mk-textarea v-model="description"><span>{{ $t('description') }}</span></mk-textarea>
+			<div class="_inputs">
+				<div v-text="$t('_theme.baseTheme')" />
+				<mk-radio v-model="baseTheme" value="light">{{ $t('light') }}</mk-radio>
+				<mk-radio v-model="baseTheme" value="dark">{{ $t('dark') }}</mk-radio>
+			</div>
+		</div>
+		<div class="_content">
+			<div class="list-view">
+				<div class="item" v-for="([ k, v ], i) in theme" :key="k">
+					<div class="_inputs">
+						<div>
+							{{ k.startsWith('$') ? `${k} (${$t('_theme.constant')})` : $t('_theme.keys.' + k) }}
+							<button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$t('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)"/>
+								<mk-input class="select" :value="v" @input="colorChanged($event, i)"/>
+							</div>
+							<!-- ref const -->
+							<mk-input v-else-if="v.type === 'refConst'" v-model="v.key">
+								<template #prefix>$</template>
+								<span>{{ $t('name') }}</span>
+							</mk-input>
+							<!-- ref props -->
+							<mk-select class="select" v-else-if="v.type === 'refProp'" v-model="v.key">
+								<option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+							</mk-select>
+							<!-- func -->
+							<template v-else-if="v.type === 'func'">
+								<mk-select class="select" v-model="v.name">
+									<template #label>{{ $t('_theme.funcKind') }}</template>
+									<option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option>
+								</mk-select>
+								<mk-input type="number" v-model="v.arg"><span>{{ $t('_theme.argument') }}</span></mk-input>
+								<mk-select class="select" v-model="v.value">
+									<template #label>{{ $t('_theme.basedProp') }}</template>
+									<option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+								</mk-select>
+							</template>
+						</div>
+					</div>
+				</div>
+				<mk-button primary @click="addConst">{{ $t('_theme.addConstant') }}</mk-button>
+			</div>
+		</div>
+		<div class="_content">
+				<mk-textarea v-model="themeToImport">
+					{{ $t('_theme.importInfo') }}
+				</mk-textarea>
+				<mk-button :disabled="!themeToImport.trim()" @click="importTheme">{{ $t('import') }}</mk-button>
+		</div>
+		<div class="_footer">
+			<mk-button inline @click="preview">{{ $t('preview') }}</mk-button>
+			<mk-button inline primary :disabled="!name || !author" @click="save">{{ $t('save') }}</mk-button>
+		</div>
+	</section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPalette, faChevronDown, faKeyboard } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+
+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 { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '../scripts/theme-editor';
+import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '../scripts/theme';
+import { toUnicode } from 'punycode';
+import { host } from '../config';
+
+export default Vue.extend({
+	components: {
+		MkRadio,
+		MkButton,
+		MkInput,
+		MkTextarea,
+		MkSelect
+	},
+	metaInfo() {
+		return {
+			title: this.$t('themeEditor') + (this.changed ? '*' : '')
+		};
+	},
+
+	data() {
+		return {
+			theme: [] as ThemeViewModel,
+			name: '',
+			description: '',
+			baseTheme: 'light' as 'dark' | 'light',
+			author: `@${this.$store.state.i.username}@${toUnicode(host)}`,
+			themeToImport: '',
+			changed: false,
+			faPalette, faChevronDown, faKeyboard,
+			lightTheme, darkTheme, themeProps,
+		}
+	},
+
+	computed: {
+		baseProps() {
+			return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props;
+		},
+	},
+
+	beforeDestroy() {
+		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 this.$root.dialog({
+				type: 'warning',
+				text: this.$t('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 this.$root.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 this.$root.dialog({
+				title: this.$t('_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 = this.$store.state.device.themes.concat(theme);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+			this.$root.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) {
+				this.$root.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.$t('_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) {
+				this.$root.dialog({
+					type: 'error',
+					text: e.message
+				});
+			}
+		},
+	
+		colorChanged(color: string, i: number) {
+			Vue.set(this.theme, i, [this.theme[i][0], color]);
+		},
+	
+		getTypeOf(v: ThemeValue) {
+			return v === null
+				? this.$t('_theme.defaultValue')
+				: typeof v === 'string'
+					? this.$t('_theme.color')
+					: this.$t('_theme.' + v.type);
+		},
+	
+		async chooseType(e: MouseEvent, i: number) {
+			const newValue = await this.showTypeMenu(e);
+			Vue.set(this.theme, i, [ this.theme[i][0], newValue ]);
+		},
+	
+		showTypeMenu(e: MouseEvent) {
+			return new Promise<ThemeValue>((resolve) => {
+				this.$root.menu({
+					items: [{
+						text: this.$t('_theme.defaultValue'),
+						action: () => resolve(null),
+					}, {
+						text: this.$t('_theme.color'),
+						action: () => resolve('#000000'),
+					}, {
+						text: this.$t('_theme.func'),
+						action: () => resolve({
+							type: 'func', name: 'alpha', arg: 1, value: 'accent'
+						}),
+					}, {
+						text: this.$t('_theme.refProp'),
+						action: () => resolve({
+							type: 'refProp', key: 'accent',
+						}),
+					}, {
+						text: this.$t('_theme.refConst'),
+						action: () => resolve({
+							type: 'refConst', key: '',
+						}),
+					},],
+					source: e.currentTarget || e.target,
+				});
+			});
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.t9makv94 {
+	> ._card {
+		> ._content {
+			> .list-view {
+				height: 480px;
+				overflow: auto;
+				border: 1px solid var(--divider);
+
+				> .item {
+					min-height: 48px;
+					padding: 0 16px;
+					word-break: break-all;
+
+					&:not(:last-child) {
+						padding-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;
+						}
+					}
+				}
+
+				> ._button {
+					margin: 16px;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/router.ts b/src/client/router.ts
index cf98c57bd7..a741aeb955 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -24,6 +24,7 @@ export const router = new VueRouter({
 		{ path: '/about-misskey', component: page('about-misskey') },
 		{ path: '/featured', component: page('featured') },
 		{ path: '/docs', component: page('docs') },
+		{ path: '/theme-editor', component: page('theme-editor') },
 		{ path: '/docs/:doc', component: page('doc'), props: true },
 		{ path: '/explore', component: page('explore') },
 		{ path: '/explore/tags/:tag', props: true, component: page('explore') },
diff --git a/src/client/scripts/theme-editor.ts b/src/client/scripts/theme-editor.ts
new file mode 100644
index 0000000000..e0c3bc25bc
--- /dev/null
+++ b/src/client/scripts/theme-editor.ts
@@ -0,0 +1,74 @@
+import { v4 as uuid} from 'uuid';
+
+import { themeProps, Theme } from './theme';
+
+export type Default = null;
+export type Color = string;
+export type FuncName = 'alpha' | 'darken' | 'lighten';
+export type Func = { type: 'func', name: FuncName, arg: number, value: string  };
+export type RefProp = { type: 'refProp', key: string  };
+export type RefConst = { type: 'refConst', key: string  };
+
+export type ThemeValue = Color | Func | RefProp | RefConst | Default;
+
+export type ThemeViewModel = [ string, ThemeValue ][];
+
+export const fromThemeString = (str?: string) : ThemeValue => {
+	if (!str) return null;
+	if (str.startsWith(':')) {
+		const parts = str.slice(1).split('<');
+		const name = parts[0] as FuncName;
+		const arg = parseFloat(parts[1]);
+		const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
+		return { type: 'func', name, arg, value };
+	} else if (str.startsWith('@')) {
+		return {
+			type: 'refProp',
+			key: str.slice(1),
+		};
+	} else if (str.startsWith('$')) {
+		return {
+			type: 'refConst',
+			key: str.slice(1),
+		};
+	} else {
+		return str;
+	}
+};
+
+export const toThemeString = (value: Color | Func | RefProp | RefConst) => {
+	if (typeof value === 'string') return value;
+	switch (value.type) {
+		case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
+		case 'refProp': return `@${value.key}`;
+		case 'refConst': return `$${value.key}`;
+	}
+};
+
+export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
+	const props = { } as { [key: string]: string };
+	for (const [ key, value ] of vm) {
+		if (value === null) continue;
+		props[key] = toThemeString(value);
+	}
+
+	return {
+		id: uuid(),
+		name, desc, author, props, base
+	};
+};
+
+export const convertToViewModel = (theme: Theme): ThemeViewModel => {
+	const vm: ThemeViewModel = [];
+	// プロパティの登録
+	vm.push(...themeProps.map(key => [ key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
+
+	// 定数の登録
+	const consts = Object
+		.keys(theme.props)
+		.filter(k => k.startsWith('$'))
+		.map(k => [ k, fromThemeString(theme.props[k]) ] as [ string, ThemeValue ]);
+
+		vm.push(...consts);
+	return vm;
+};
diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts
index d458df45f0..30eaf77e01 100644
--- a/src/client/scripts/theme.ts
+++ b/src/client/scripts/theme.ts
@@ -12,6 +12,8 @@ export type Theme = {
 export const lightTheme: Theme = require('../themes/_light.json5');
 export const darkTheme: Theme = require('../themes/_dark.json5');
 
+export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
+
 export const builtinThemes = [
 	require('../themes/white.json5'),
 	require('../themes/black.json5'),
diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5
index 4e5225db36..8d7a345fd5 100644
--- a/src/client/themes/_dark.json5
+++ b/src/client/themes/_dark.json5
@@ -12,12 +12,10 @@
 		accent: '#86b300',
 		accentDarken: ':darken<10<@accent',
 		accentLighten: ':lighten<10<@accent',
-		accentShadow: ':alpha<0.3<@accent',
 		focus: ':alpha<0.3<@accent',
 		bg: '#000',
 		fg: '#c7d1d8',
 		fgHighlighted: ':lighten<3<@fg',
-		html: '@bg',
 		divider: 'rgba(255, 255, 255, 0.1)',
 		indicator: '@accent',
 		panel: '#000',
diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5
index 2317ddef65..4e499e178c 100644
--- a/src/client/themes/_light.json5
+++ b/src/client/themes/_light.json5
@@ -12,12 +12,10 @@
 		accent: '#86b300',
 		accentDarken: ':darken<10<@accent',
 		accentLighten: ':lighten<10<@accent',
-		accentShadow: ':alpha<0.4<@accent',
 		focus: ':alpha<0.3<@accent',
 		bg: '#fafafa',
 		fg: '#5c6a73',
 		fgHighlighted: ':darken<3<@fg',
-		html: '@bg',
 		divider: 'rgba(0, 0, 0, 0.1)',
 		indicator: '@accent',
 		panel: '#fff',
diff --git a/src/client/themes/halloween.json5 b/src/client/themes/halloween.json5
index 7cabf01d1e..1394c793ed 100644
--- a/src/client/themes/halloween.json5
+++ b/src/client/themes/halloween.json5
@@ -12,7 +12,6 @@
 		panel: '#1f1d30',
 		bg: '#0f0e17',
 		fg: '#b1bee3',
-		html: '@accent',
 		renote: '@accent',
 	},
 }