diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 8a4d346bc2..e99d9b5350 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -127,6 +127,7 @@ cacheRemoteFilesDescription: "この設定を無効にすると、リモート
 flagAsBot: "Botとして設定"
 flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。"
 flagAsCat: "Catとして設定"
+flagAsCatDescription: "このアカウントが猫であることを示す場合は、このフラグをオンにします。"
 autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認"
 addAcount: "アカウント追加"
 loginFailed: "ログインに失敗しました"
@@ -440,6 +441,7 @@ useOsNativeEmojis: "OSネイティブの絵文字を使用"
 youHaveNoGroups: "グループがありません"
 joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループを作成してください。"
 noHistory: "履歴はありません"
+signinHistory: "ログイン履歴"
 disableAnimatedMfm: "動きのあるMFMを無効にする"
 doing: "やっています"
 category: "カテゴリ"
@@ -492,6 +494,7 @@ none: "なし"
 showInPage: "ページで表示"
 popout: "ポップアウト"
 volume: "音量"
+masterVolume: "マスター音量"
 details: "詳細"
 chooseEmoji: "絵文字を選択"
 unableToProcess: "操作を完了できません"
@@ -564,7 +567,8 @@ useStarForReactionFallback: "リアクション絵文字が不明な場合、代
 emailConfig: "メールサーバー設定"
 enableEmail: "メール配信機能を有効化する"
 emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います"
-email: "メールアドレス"
+email: "メール"
+emailAddress: "メールアドレス"
 smtpConfig: "SMTP サーバーの設定"
 smtpHost: "ホスト"
 smtpPort: "ポート"
@@ -596,6 +600,7 @@ regenerateLoginTokenDescription: "ログインに使用される内部トーク
 setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。"
 fileIdOrUrl: "ファイルIDまたはURL"
 chatOpenBehavior: "チャットを開くときの動作"
+behavior: "動作"
 sample: "サンプル"
 abuseReports: "通報"
 reportAbuse: "通報"
@@ -619,6 +624,42 @@ createNew: "新規作成"
 optional: "任意"
 createNewClip: "新しいクリップを作成"
 public: "パブリック"
+i18nInfo: "Misskeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。"
+manageAccessTokens: "アクセストークンの管理"
+accountInfo: "アカウント情報"
+notesCount: "ノートの数"
+repliesCount: "返信した数"
+renotesCount: "Renoteした数"
+repliedCount: "返信された数"
+renotedCount: "Renoteされた数"
+followingCount: "フォロー数"
+followersCount: "フォロワー数"
+sentReactionsCount: "リアクションした数"
+receivedReactionsCount: "リアクションされた数"
+pollVotesCount: "アンケートに投票した数"
+pollVotedCount: "アンケートに投票された数"
+yes: "はい"
+no: "いいえ"
+driveFilesCount: "ドライブのファイル数"
+driveUsage: "ドライブ使用量"
+noCrawle: "クローラーによるインデックスを拒否"
+noCrawleDescription: "検索エンジンにあなたのユーザーページ、ノート、Pagesなどのコンテンツを登録(インデックス)しないよう要請します。"
+lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。"
+alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする"
+loadRawImages: "添付画像のサムネイルをオリジナル画質にする"
+disableShowingAnimatedImages: "アニメーション画像を再生しない"
+verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。"
+notSet: "未設定"
+emailVerified: "メールアドレスが確認されました"
+noteFavoritesCount: "お気に入りノートの数"
+pageLikesCount: "Pageにいいねした数"
+pageLikedCount: "Pageにいいねされた数"
+reversiCount: "リバーシの対局数"
+
+_nsfw:
+  respect: "閲覧注意のメディアは隠す"
+  ignore: "閲覧注意のメディアを隠さない"
+  force: "常にメディアを隠す"
 
 _mfm:
   cheatSheet: "MFMチートシート"
@@ -745,6 +786,8 @@ _theme:
   manage: "テーマの管理"
   code: "テーマコード"
   installed: "{name}をインストールしました"
+  installedThemes: "インストールされたテーマ"
+  builtinThemes: "標準のテーマ"
   alreadyInstalled: "そのテーマは既にインストールされています"
   invalid: "テーマの形式が間違っています"
   make: "テーマを作る"
@@ -820,6 +863,8 @@ _sfx:
   chatBg: "チャット(バックグラウンド)"
   antenna: "アンテナ受信"
   channel: "チャンネル通知"
+  reversiPutBlack: "リバーシ: 黒が打ったとき"
+  reversiPutWhite: "リバーシ: 白が打ったとき"
 
 _ago:
   unknown: "謎"
@@ -999,7 +1044,9 @@ _profile:
   username: "ユーザー名"
   description: "自己紹介"
   youCanIncludeHashtags: "ハッシュタグを含めることができます。"
-  metadata: "補足情報"
+  metadata: "追加情報"
+  metadataEdit: "追加情報を編集"
+  metadataDescription: "プロフィールに表として4つまでの追加情報を表示することができます。"
   metadataLabel: "ラベル"
   metadataContent: "内容"
 
diff --git a/migration/1605585339718-instance-pinned-pages.ts b/migration/1605585339718-instance-pinned-pages.ts
index 2f0ebab235..f593461306 100644
--- a/migration/1605585339718-instance-pinned-pages.ts
+++ b/migration/1605585339718-instance-pinned-pages.ts
@@ -4,7 +4,7 @@ export class instancePinnedPages1605585339718 implements MigrationInterface {
     name = 'instancePinnedPages1605585339718'
 
     public async up(queryRunner: QueryRunner): Promise<void> {
-        await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{"/announcements", "/featured", "/channels", "/pages", "/explore", "/games/reversi", "/about-misskey"}'::varchar[]`);
+        await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{"/featured", "/channels", "/explore", "/pages", "/about-misskey"}'::varchar[]`);
     }
 
     public async down(queryRunner: QueryRunner): Promise<void> {
diff --git a/migration/1605965516823-instance-images.ts b/migration/1605965516823-instance-images.ts
new file mode 100644
index 0000000000..bf8d408563
--- /dev/null
+++ b/migration/1605965516823-instance-images.ts
@@ -0,0 +1,16 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class instanceImages1605965516823 implements MigrationInterface {
+    name = 'instanceImages1605965516823'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "meta" ADD "backgroundImageUrl" character varying(512)`);
+        await queryRunner.query(`ALTER TABLE "meta" ADD "logoImageUrl" character varying(512)`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "logoImageUrl"`);
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "backgroundImageUrl"`);
+    }
+
+}
diff --git a/migration/1606191203881-no-crawle.ts b/migration/1606191203881-no-crawle.ts
new file mode 100644
index 0000000000..accc8f8fe2
--- /dev/null
+++ b/migration/1606191203881-no-crawle.ts
@@ -0,0 +1,16 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class noCrawle1606191203881 implements MigrationInterface {
+    name = 'noCrawle1606191203881'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user_profile" ADD "noCrawle" boolean NOT NULL DEFAULT false`);
+        await queryRunner.query(`COMMENT ON COLUMN "user_profile"."noCrawle" IS 'Whether reject index by crawler.'`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`COMMENT ON COLUMN "user_profile"."noCrawle" IS 'Whether reject index by crawler.'`);
+        await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "noCrawle"`);
+    }
+
+}
diff --git a/src/client/assets/sounds/syuilo/kick.mp3 b/src/client/assets/sounds/syuilo/kick.mp3
new file mode 100644
index 0000000000..4e0e72091c
Binary files /dev/null and b/src/client/assets/sounds/syuilo/kick.mp3 differ
diff --git a/src/client/assets/sounds/syuilo/snare.mp3 b/src/client/assets/sounds/syuilo/snare.mp3
new file mode 100644
index 0000000000..9244189c2d
Binary files /dev/null and b/src/client/assets/sounds/syuilo/snare.mp3 differ
diff --git a/src/client/cold-storage.ts b/src/client/cold-storage.ts
new file mode 100644
index 0000000000..1bee2313fa
--- /dev/null
+++ b/src/client/cold-storage.ts
@@ -0,0 +1,34 @@
+// 常にメモリにロードしておく必要がないような設定情報を保管するストレージ
+
+const PREFIX = 'miux:';
+
+export const defaultDeviceSettings = {
+	sound_masterVolume: 0.3,
+	sound_note: { type: 'syuilo/down', volume: 1 },
+	sound_noteMy: { type: 'syuilo/up', volume: 1 },
+	sound_notification: { type: 'syuilo/pope2', volume: 1 },
+	sound_chat: { type: 'syuilo/pope1', volume: 1 },
+	sound_chatBg: { type: 'syuilo/waon', volume: 1 },
+	sound_antenna: { type: 'syuilo/triple', volume: 1 },
+	sound_channel: { type: 'syuilo/square-pico', volume: 1 },
+	sound_reversiPutBlack: { type: 'syuilo/kick', volume: 0.3 },
+	sound_reversiPutWhite: { type: 'syuilo/snare', volume: 0.3 },
+};
+
+export const device = {
+	get<T extends keyof typeof defaultDeviceSettings>(key: T): typeof defaultDeviceSettings[T] {
+		// TODO: indexedDBにする
+		//       ただしその際はnullチェックではなくキー存在チェックにしないとダメ
+		//       (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある)
+		const value = localStorage.getItem(PREFIX + key);
+		if (value == null) {
+			return defaultDeviceSettings[key];
+		} else {
+			return JSON.parse(value);
+		}
+	},
+
+	set(key: keyof typeof defaultDeviceSettings, value: any): any {
+		localStorage.setItem(PREFIX + key, JSON.stringify(value));
+	},
+};
diff --git a/src/client/components/form-dialog.vue b/src/client/components/form-dialog.vue
index 0dc02258af..add6b230d3 100644
--- a/src/client/components/form-dialog.vue
+++ b/src/client/components/form-dialog.vue
@@ -1,6 +1,6 @@
 <template>
 <XModalWindow ref="dialog"
-	:width="400"
+	:width="450"
 	:can-close="false"
 	:with-ok-button="true"
 	:ok-button-disabled="false"
@@ -12,42 +12,61 @@
 	<template #header>
 		{{ title }}
 	</template>
-	<div class="xkpnjxcv _section">
-		<label v-for="item in Object.keys(form).filter(item => !form[item].hidden)" :key="item">
-			<MkInput v-if="form[item].type === 'number'" v-model:value="values[item]" type="number" :step="form[item].step || 1">
+	<FormBase class="xkpnjxcv">
+		<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
+			<FormInput v-if="form[item].type === 'number'" v-model:value="values[item]" type="number" :step="form[item].step || 1">
 				<span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span>
 				<template v-if="form[item].description" #desc>{{ form[item].description }}</template>
-			</MkInput>
-			<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model:value="values[item]" type="text">
+			</FormInput>
+			<FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model:value="values[item]" type="text">
 				<span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span>
 				<template v-if="form[item].description" #desc>{{ form[item].description }}</template>
-			</MkInput>
-			<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model:value="values[item]">
+			</FormInput>
+			<FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model:value="values[item]">
 				<span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span>
 				<template v-if="form[item].description" #desc>{{ form[item].description }}</template>
-			</MkTextarea>
-			<MkSwitch v-else-if="form[item].type === 'boolean'" v-model:value="values[item]">
+			</FormTextarea>
+			<FormSwitch v-else-if="form[item].type === 'boolean'" v-model:value="values[item]">
 				<span v-text="form[item].label || item"></span>
 				<template v-if="form[item].description" #desc>{{ form[item].description }}</template>
-			</MkSwitch>
-		</label>
-	</div>
+			</FormSwitch>
+			<FormSelect v-else-if="form[item].type === 'enum'" v-model:value="values[item]">
+				<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span></template>
+				<option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option>
+			</FormSelect>
+			<FormRange v-else-if="form[item].type === 'range'" v-model:value="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step">
+				<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span></template>
+				<template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+			</FormRange>
+			<FormButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
+				<span v-text="form[item].content || item"></span>
+			</FormButton>
+		</template>
+	</FormBase>
 </XModalWindow>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import XModalWindow from '@/components/ui/modal-window.vue';
-import MkInput from './ui/input.vue';
-import MkTextarea from './ui/textarea.vue';
-import MkSwitch from './ui/switch.vue';
+import FormBase from './form/base.vue';
+import FormInput from './form/input.vue';
+import FormTextarea from './form/textarea.vue';
+import FormSwitch from './form/switch.vue';
+import FormSelect from './form/select.vue';
+import FormRange from './form/range.vue';
+import FormButton from './form/button.vue';
 
 export default defineComponent({
 	components: {
 		XModalWindow,
-		MkInput,
-		MkTextarea,
-		MkSwitch,
+		FormBase,
+		FormInput,
+		FormTextarea,
+		FormSwitch,
+		FormSelect,
+		FormRange,
+		FormButton,
 	},
 
 	props: {
@@ -95,12 +114,6 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .xkpnjxcv {
-	> label {
-		display: block;
 
-		&:not(:last-child) {
-			margin-bottom: 32px;
-		}
-	}
 }
 </style>
diff --git a/src/client/components/form/base.vue b/src/client/components/form/base.vue
new file mode 100644
index 0000000000..249b49c675
--- /dev/null
+++ b/src/client/components/form/base.vue
@@ -0,0 +1,56 @@
+<template>
+<div class="rbusrurv" :class="{ wide: forceWide }" v-size="{ max: [400] }">
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+	props: {
+		forceWide: {
+			type: Boolean,
+			required: false,
+			default: false,
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.rbusrurv {
+	line-height: 1.4em;
+	background: var(--bg);
+	padding: 32px;
+
+	&:not(.wide).max-width_400px {
+		padding: 32px 0;
+
+		> ::v-deep(*) {
+			._formPanel {
+				border: solid 0.5px var(--divider);
+				border-radius: 0;
+				border-left: none;
+				border-right: none;
+			}
+
+			._form_group {
+				> * {
+					&:not(:first-child) {
+						&._formPanel, ._formPanel {
+							border-top: none;
+						}
+					}
+
+					&:not(:last-child) {
+						&._formPanel, ._formPanel {
+							border-bottom: solid 0.5px var(--divider);
+						}
+					}
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/button.vue b/src/client/components/form/button.vue
new file mode 100644
index 0000000000..b4f0890945
--- /dev/null
+++ b/src/client/components/form/button.vue
@@ -0,0 +1,81 @@
+<template>
+<div class="yzpgjkxe _formItem">
+	<div class="_formLabel"><slot name="label"></slot></div>
+	<button class="main _button _formPanel _formClickable" :class="{ center, primary, danger }">
+		<slot></slot>
+		<div class="suffix">
+			<slot name="suffix"></slot>
+			<div class="icon">
+				<slot name="suffixIcon"></slot>
+			</div>
+		</div>
+	</button>
+	<div class="_formCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './form.scss';
+
+export default defineComponent({
+	props: {
+		primary: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		danger: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		disabled: {
+			type: Boolean,
+			required: false,
+			default: false,
+		},
+		center: {
+			type: Boolean,
+			required: false,
+			default: true,
+		}
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.yzpgjkxe {
+	> .main {
+		display: flex;
+		width: 100%;
+		box-sizing: border-box;
+		padding: 14px 16px;
+		text-align: left;
+		align-items: center;
+
+		&.center {
+			display: block;
+			text-align: center;
+		}
+
+		&.primary {
+			color: var(--accent);
+		}
+
+		&.danger {
+			color: #ff2a2a;
+		}
+
+		> .suffix {
+			display: inline-flex;
+			margin-left: auto;
+			opacity: 0.7;
+
+			> .icon {
+				margin-left: 1em;
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/form.scss b/src/client/components/form/form.scss
new file mode 100644
index 0000000000..b541bf826d
--- /dev/null
+++ b/src/client/components/form/form.scss
@@ -0,0 +1,34 @@
+._formPanel {
+	background: var(--panel);
+	border-radius: var(--radius);
+
+	&._formClickable {
+		&:hover {
+			background: var(--panelHighlight);
+		}
+	}
+}
+
+._formLabel {
+	font-size: 80%;
+	padding: 0 16px 8px 16px;
+
+	&:empty {
+		display: none;
+	}
+}
+
+._formCaption {
+	font-size: 80%;
+	padding: 8px 16px 0 16px;
+
+	&:empty {
+		display: none;
+	}
+}
+
+._formItem {
+	& + ._formItem {
+		margin-top: 24px;
+	}
+}
diff --git a/src/client/components/form/group.vue b/src/client/components/form/group.vue
new file mode 100644
index 0000000000..d07852155a
--- /dev/null
+++ b/src/client/components/form/group.vue
@@ -0,0 +1,42 @@
+<template>
+<div class="vrtktovg _formItem" v-size="{ max: [500] }">
+	<div class="_formLabel"><slot name="label"></slot></div>
+	<div class="main _form_group">
+		<slot></slot>
+	</div>
+	<div class="_formCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+});
+</script>
+
+<style lang="scss" scoped>
+.vrtktovg {
+	> .main {
+		> ::v-deep(*) {
+			margin: 0;
+
+			&:not(:first-child) {
+				&._formPanel, ._formPanel {
+					border-top: none;
+					border-top-left-radius: 0;
+					border-top-right-radius: 0;
+				}
+			}
+
+			&:not(:last-child) {
+				&._formPanel, ._formPanel {
+					border-bottom: solid 0.5px var(--divider);
+					border-bottom-left-radius: 0;
+					border-bottom-right-radius: 0;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue
new file mode 100644
index 0000000000..89551a5fc2
--- /dev/null
+++ b/src/client/components/form/input.vue
@@ -0,0 +1,306 @@
+<template>
+<div class="ztzhwixg _formItem" :class="{ inline, disabled }">
+	<div class="_formLabel"><slot></slot></div>
+	<div class="icon" ref="icon"><slot name="icon"></slot></div>
+	<div class="input _formPanel">
+		<div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+		<input v-if="debounce" ref="inputEl"
+			v-debounce="500"
+			:type="type"
+			v-model.lazy="v"
+			:disabled="disabled"
+			:required="required"
+			:readonly="readonly"
+			:placeholder="placeholder"
+			:pattern="pattern"
+			:autocomplete="autocomplete"
+			:spellcheck="spellcheck"
+			:step="step"
+			@focus="focused = true"
+			@blur="focused = false"
+			@keydown="onKeydown($event)"
+			@input="onInput"
+			:list="id"
+		>
+		<input v-else ref="inputEl"
+			:type="type"
+			v-model="v"
+			:disabled="disabled"
+			:required="required"
+			:readonly="readonly"
+			:placeholder="placeholder"
+			:pattern="pattern"
+			:autocomplete="autocomplete"
+			:spellcheck="spellcheck"
+			:step="step"
+			@focus="focused = true"
+			@blur="focused = false"
+			@keydown="onKeydown($event)"
+			@input="onInput"
+			:list="id"
+		>
+		<datalist :id="id" v-if="datalist">
+			<option v-for="data in datalist" :value="data"/>
+		</datalist>
+		<div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
+	</div>
+	<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
+	<div class="_formCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import debounce from 'v-debounce';
+import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
+import './form.scss';
+
+export default defineComponent({
+	directives: {
+		debounce
+	},
+	props: {
+		value: {
+			required: false
+		},
+		type: {
+			type: String,
+			required: false
+		},
+		required: {
+			type: Boolean,
+			required: false
+		},
+		readonly: {
+			type: Boolean,
+			required: false
+		},
+		disabled: {
+			type: Boolean,
+			required: false
+		},
+		pattern: {
+			type: String,
+			required: false
+		},
+		placeholder: {
+			type: String,
+			required: false
+		},
+		autofocus: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		autocomplete: {
+			required: false
+		},
+		spellcheck: {
+			required: false
+		},
+		step: {
+			required: false
+		},
+		debounce: {
+			required: false
+		},
+		datalist: {
+			type: Array,
+			required: false,
+		},
+		inline: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		save: {
+			type: Function,
+			required: false,
+		},
+	},
+	emits: ['change', 'keydown', 'enter'],
+	setup(props, context) {
+		const { value, type, autofocus } = toRefs(props);
+		const v = ref(value.value);
+		const id = Math.random().toString(); // TODO: uuid?
+		const focused = ref(false);
+		const changed = ref(false);
+		const invalid = ref(false);
+		const filled = computed(() => v.value !== '' && v.value != null);
+		const inputEl = ref(null);
+		const prefixEl = ref(null);
+		const suffixEl = ref(null);
+
+		const focus = () => inputEl.value.focus();
+		const onInput = (ev) => {
+			changed.value = true;
+			context.emit('change', ev);
+		};
+		const onKeydown = (ev: KeyboardEvent) => {
+			context.emit('keydown', ev);
+
+			if (ev.code === 'Enter') {
+				context.emit('enter');
+			}
+		};
+
+		watch(value, newValue => {
+			v.value = newValue;
+		});
+
+		watch(v, newValue => {
+			if (type?.value === 'number') {
+				context.emit('update:value', parseFloat(newValue));
+			} else {
+				context.emit('update:value', newValue);
+			}
+
+			invalid.value = inputEl.value.validity.badInput;
+		});
+
+		onMounted(() => {
+			nextTick(() => {
+				if (autofocus.value) {
+					focus();
+				}
+
+				// このコンポーネントが作成された時、非表示状態である場合がある
+				// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+				const clock = setInterval(() => {
+					if (prefixEl.value) {
+						if (prefixEl.value.offsetWidth) {
+							inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+						}
+					}
+					if (suffixEl.value) {
+						if (suffixEl.value.offsetWidth) {
+							inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+						}
+					}
+				}, 100);
+
+				onUnmounted(() => {
+					clearInterval(clock);
+				});
+			});
+		});
+
+		return {
+			id,
+			v,
+			focused,
+			invalid,
+			changed,
+			filled,
+			inputEl,
+			prefixEl,
+			suffixEl,
+			focus,
+			onInput,
+			onKeydown,
+			faExclamationCircle,
+		};
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.ztzhwixg {
+	position: relative;
+
+	> .icon {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 24px;
+		text-align: center;
+		line-height: 32px;
+
+		&:not(:empty) + .input {
+			margin-left: 28px;
+		}
+	}
+
+	> .input {
+		$height: 52px;
+		position: relative;
+
+		> input {
+			display: block;
+			height: $height;
+			width: 100%;
+			margin: 0;
+			padding: 0 16px;
+			font: inherit;
+			font-weight: normal;
+			font-size: 1em;
+			line-height: $height;
+			color: var(--inputText);
+			background: transparent;
+			border: none;
+			border-radius: 0;
+			outline: none;
+			box-shadow: none;
+			box-sizing: border-box;
+
+			&[type='file'] {
+				display: none;
+			}
+		}
+
+		> .prefix,
+		> .suffix {
+			display: block;
+			position: absolute;
+			z-index: 1;
+			top: 0;
+			padding: 0 16px;
+			font-size: 1em;
+			line-height: $height;
+			color: var(--inputLabel);
+			pointer-events: none;
+
+			&:empty {
+				display: none;
+			}
+
+			> * {
+				display: inline-block;
+				min-width: 16px;
+				max-width: 150px;
+				overflow: hidden;
+				white-space: nowrap;
+				text-overflow: ellipsis;
+			}
+		}
+
+		> .prefix {
+			left: 0;
+			padding-right: 8px;
+		}
+
+		> .suffix {
+			right: 0;
+			padding-left: 8px;
+		}
+	}
+
+	> .save {
+		margin: 6px 0 0 0;
+		font-size: 0.8em;
+	}
+
+	&.inline {
+		display: inline-block;
+		margin: 0;
+	}
+
+	&.disabled {
+		opacity: 0.7;
+
+		&, * {
+			cursor: not-allowed !important;
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/key-value-view.vue b/src/client/components/form/key-value-view.vue
new file mode 100644
index 0000000000..eadc675f89
--- /dev/null
+++ b/src/client/components/form/key-value-view.vue
@@ -0,0 +1,30 @@
+<template>
+<div class="_formItem">
+	<div class="_formPanel anocepby">
+		<span class="key"><slot name="key"></slot></span>
+		<span class="value"><slot name="value"></slot></span>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './form.scss';
+
+export default defineComponent({
+
+});
+</script>
+
+<style lang="scss" scoped>
+.anocepby {
+	display: flex;
+	align-items: center;
+	padding: 14px 16px;
+
+	> .value {
+		margin-left: auto;
+		opacity: 0.7;
+	}
+}
+</style>
diff --git a/src/client/components/form/link.vue b/src/client/components/form/link.vue
new file mode 100644
index 0000000000..01c46e851a
--- /dev/null
+++ b/src/client/components/form/link.vue
@@ -0,0 +1,90 @@
+<template>
+<div class="qmfkfnzi _formItem">
+	<a class="main _button _formPanel _formClickable" :href="to" target="_blank" v-if="external">
+		<span class="icon"><slot name="icon"></slot></span>
+		<span class="text"><slot></slot></span>
+		<Fa :icon="faExternalLinkAlt" class="right"/>
+	</a>
+	<MkA class="main _button _formPanel _formClickable" :class="{ active }" :to="to" v-else>
+		<span class="icon"><slot name="icon"></slot></span>
+		<span class="text"><slot></slot></span>
+		<Fa :icon="faChevronRight" class="right"/>
+	</MkA>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faChevronRight, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
+import './form.scss';
+
+export default defineComponent({
+	props: {
+		to: {
+			type: String,
+			required: true
+		},
+		active: {
+			type: Boolean,
+			required: false
+		},
+		external: {
+			type: Boolean,
+			required: false
+		},
+	},
+	data() {
+		return {
+			faChevronRight, faExternalLinkAlt
+		};
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.qmfkfnzi {
+	> .main {
+		display: flex;
+		align-items: center;
+		width: 100%;
+		box-sizing: border-box;
+		padding: 14px 16px 14px 14px;
+
+		&:hover {
+			text-decoration: none;
+		}
+
+		&.active {
+			color: var(--accent);
+		}
+
+		> .icon {
+			width: 32px;
+			margin-right: 2px;
+			flex-shrink: 0;
+			text-align: center;
+			opacity: 0.8;
+
+			&:empty {
+				display: none;
+
+				& + .text {
+					padding-left: 4px;
+				}
+			}
+		}
+
+		> .text {
+			white-space: nowrap;
+			text-overflow: ellipsis;
+			overflow: hidden;
+			padding-right: 12px;
+		}
+
+		> .right {
+			margin-left: auto;
+			opacity: 0.7;
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/pagination.vue b/src/client/components/form/pagination.vue
new file mode 100644
index 0000000000..7dcaedf9bf
--- /dev/null
+++ b/src/client/components/form/pagination.vue
@@ -0,0 +1,42 @@
+<template>
+<FormGroup class="uljviswt _formItem">
+	<template #label><slot name="label"></slot></template>
+	<slot :items="items"></slot>
+	<div class="empty" v-if="empty" key="_empty_">
+		<slot name="empty"></slot>
+	</div>
+	<FormButton v-show="more" class="button" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
+		<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
+		<template v-if="moreFetching"><MkLoading inline/></template>
+	</FormButton>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
+import paging from '@/scripts/paging';
+
+export default defineComponent({
+	components: {
+		FormButton,
+		FormGroup,
+	},
+
+	mixins: [
+		paging({}),
+	],
+
+	props: {
+		pagination: {
+			required: true
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.uljviswt {
+}
+</style>
diff --git a/src/client/components/form/radios.vue b/src/client/components/form/radios.vue
new file mode 100644
index 0000000000..4c7f405cac
--- /dev/null
+++ b/src/client/components/form/radios.vue
@@ -0,0 +1,106 @@
+<script lang="ts">
+import { defineComponent, h } from 'vue';
+import MkRadio from '@/components/ui/radio.vue';
+import './form.scss';
+
+export default defineComponent({
+	components: {
+		MkRadio
+	},
+	props: {
+		modelValue: {
+			required: false
+		},
+	},
+	data() {
+		return {
+			value: this.modelValue,
+		}
+	},
+	watch: {
+		value() {
+			this.$emit('update:modelValue', this.value);
+		}
+	},
+	render() {
+		const label = this.$slots.desc();
+		const options = this.$slots.default();
+
+		return h('div', {
+			class: 'cnklmpwm _formItem'
+		}, [
+			h('div', {
+				class: '_formLabel',
+			}, label),
+			...options.map(option => h('button', {
+				class: '_button _formPanel _formClickable',
+				key: option.props.value,
+				onClick: () => this.value = option.props.value,
+			}, [h('span', {
+				class: ['check', { checked: this.value === option.props.value }],
+			}), option.children]))
+		]);
+	}
+});
+</script>
+
+<style lang="scss">
+.cnklmpwm {
+	> button {
+		display: block;
+		width: 100%;
+		box-sizing: border-box;
+		padding: 14px 18px;
+		text-align: left;
+
+		&:not(:first-of-type) {
+			border-top: none !important;
+			border-top-left-radius: 0;
+			border-top-right-radius: 0;
+		}
+
+		&:not(:last-of-type) {
+			border-bottom: solid 0.5px var(--divider);
+			border-bottom-left-radius: 0;
+			border-bottom-right-radius: 0;
+		}
+
+		> .check {
+			display: inline-block;
+			vertical-align: bottom;
+			position: relative;
+			width: 20px;
+			height: 20px;
+			margin-right: 8px;
+			background: none;
+			border: 2px solid var(--inputBorder);
+			border-radius: 100%;
+			transition: inherit;
+
+			&:after {
+				content: "";
+				display: block;
+				position: absolute;
+				top: 3px;
+				right: 3px;
+				bottom: 3px;
+				left: 3px;
+				border-radius: 100%;
+				opacity: 0;
+				transform: scale(0);
+				transition: .4s cubic-bezier(.25,.8,.25,1);
+			}
+
+			&.checked {
+				border-color: var(--accent);
+
+				&:after {
+					background-color: var(--accent);
+					transform: scale(1);
+					opacity: 1;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/range.vue b/src/client/components/form/range.vue
new file mode 100644
index 0000000000..3452184c55
--- /dev/null
+++ b/src/client/components/form/range.vue
@@ -0,0 +1,122 @@
+<template>
+<div class="ifitouly _formItem" :class="{ focused, disabled }">
+	<div class="_formLabel"><slot name="label"></slot></div>
+	<div class="_formPanel main">
+		<input
+			type="range"
+			ref="input"
+			v-model="v"
+			:disabled="disabled"
+			:min="min"
+			:max="max"
+			:step="step"
+			@focus="focused = true"
+			@blur="focused = false"
+			@input="$emit('update:value', $event.target.value)"
+		/>
+	</div>
+	<div class="_formCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+	props: {
+		value: {
+			type: Number,
+			required: false,
+			default: 0
+		},
+		disabled: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		min: {
+			type: Number,
+			required: false,
+			default: 0
+		},
+		max: {
+			type: Number,
+			required: false,
+			default: 100
+		},
+		step: {
+			type: Number,
+			required: false,
+			default: 1
+		},
+	},
+	data() {
+		return {
+			v: this.value,
+			focused: false
+		};
+	},
+	watch: {
+		value(v) {
+			this.v = parseFloat(v);
+		}
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.ifitouly {
+	position: relative;
+
+	> .main {
+		padding: 24px 16px;
+
+		> input {
+			display: block;
+			-webkit-appearance: none;
+			-moz-appearance: none;
+			appearance: none;
+			background: var(--X10);
+			height: 4px;
+			width: 100%;
+			box-sizing: border-box;
+			margin: 0;
+			outline: 0;
+			border: 0;
+			border-radius: 7px;
+
+			&.disabled {
+				opacity: 0.6;
+				cursor: not-allowed;
+			}
+
+			&::-webkit-slider-thumb {
+				-webkit-appearance: none;
+				appearance: none;
+				cursor: pointer;
+				width: 20px;
+				height: 20px;
+				display: block;
+				border-radius: 50%;
+				border: none;
+				background: var(--accent);
+				box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+				box-sizing: content-box;
+			}
+
+			&::-moz-range-thumb {
+				-moz-appearance: none;
+				appearance: none;
+				cursor: pointer;
+				width: 20px;
+				height: 20px;
+				display: block;
+				border-radius: 50%;
+				border: none;
+				background: var(--accent);
+				box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/select.vue b/src/client/components/form/select.vue
new file mode 100644
index 0000000000..b865372f56
--- /dev/null
+++ b/src/client/components/form/select.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="yrtfrpux _formItem" :class="{ disabled, inline }">
+	<div class="_formLabel"><slot name="label"></slot></div>
+	<div class="icon" ref="icon"><slot name="icon"></slot></div>
+	<div class="input _formPanel _formClickable" @click="focus">
+		<div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
+		<select ref="input"
+			v-model="v"
+			:required="required"
+			:disabled="disabled"
+			@focus="focused = true"
+			@blur="focused = false"
+		>
+			<slot></slot>
+		</select>
+		<div class="suffix">
+			<Fa :icon="faChevronDown"/>
+		</div>
+	</div>
+	<div class="_formCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
+import './form.scss';
+
+export default defineComponent({
+	props: {
+		value: {
+			required: false
+		},
+		required: {
+			type: Boolean,
+			required: false
+		},
+		disabled: {
+			type: Boolean,
+			required: false
+		},
+		inline: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+	},
+	data() {
+		return {
+			faChevronDown,
+		};
+	},
+	computed: {
+		v: {
+			get() {
+				return this.value;
+			},
+			set(v) {
+				this.$emit('update:value', v);
+			}
+		},
+	},
+	methods: {
+		focus() {
+			this.$refs.input.focus();
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.yrtfrpux {
+	position: relative;
+
+	> .icon {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 24px;
+		text-align: center;
+		line-height: 32px;
+
+		&:not(:empty) + .input {
+			margin-left: 28px;
+		}
+	}
+
+	> .input {
+		display: flex;
+		position: relative;
+
+		> select {
+			display: block;
+			flex: 1;
+			width: 100%;
+			padding: 0 16px;
+			font: inherit;
+			font-weight: normal;
+			font-size: 1em;
+			height: 52px;
+			background: none;
+			border: none;
+			border-radius: 0;
+			outline: none;
+			box-shadow: none;
+			appearance: none;
+			-webkit-appearance: none;
+			color: var(--fg);
+
+			option,
+			optgroup {
+				color: var(--fg);
+				background: var(--bg);
+			}
+		}
+
+		> .prefix,
+		> .suffix {
+			display: block;
+			align-self: center;
+			justify-self: center;
+			font-size: 1em;
+			line-height: 32px;
+			color: var(--inputLabel);
+			pointer-events: none;
+
+			&:empty {
+				display: none;
+			}
+
+			> * {
+				display: block;
+				min-width: 16px;
+			}
+		}
+
+		> .prefix {
+			padding-right: 4px;
+		}
+
+		> .suffix {
+			padding: 0 16px 0 0;
+			opacity: 0.7;
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/switch.vue b/src/client/components/form/switch.vue
new file mode 100644
index 0000000000..a2941c5996
--- /dev/null
+++ b/src/client/components/form/switch.vue
@@ -0,0 +1,132 @@
+<template>
+<div class="ijnpvmgr _formItem">
+	<div class="main _formPanel _formClickable"
+		:class="{ disabled, checked }"
+		:aria-checked="checked"
+		:aria-disabled="disabled"
+		@click.prevent="toggle"
+	>
+		<input
+			type="checkbox"
+			ref="input"
+			:disabled="disabled"
+			@keydown.enter="toggle"
+		>
+		<span class="button">
+			<span></span>
+		</span>
+		<span class="label">
+			<span><slot></slot></span>
+		</span>
+	</div>
+	<div class="_formCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './form.scss';
+
+export default defineComponent({
+	props: {
+		value: {
+			type: Boolean,
+			default: false
+		},
+		disabled: {
+			type: Boolean,
+			default: false
+		}
+	},
+	computed: {
+		checked(): boolean {
+			return this.value;
+		}
+	},
+	methods: {
+		toggle() {
+			if (this.disabled) return;
+			this.$emit('update:value', !this.checked);
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.ijnpvmgr {
+	> .main {
+		position: relative;
+		display: flex;
+		padding: 16px;
+		cursor: pointer;
+
+		> * {
+			user-select: none;
+		}
+
+		&.disabled {
+			opacity: 0.6;
+			cursor: not-allowed;
+		}
+
+		&.checked {
+			> .button {
+				background-color: var(--X10);
+				border-color: var(--X10);
+
+				> * {
+					background-color: var(--accent);
+					transform: translateX(14px);
+				}
+			}
+		}
+
+		> input {
+			position: absolute;
+			width: 0;
+			height: 0;
+			opacity: 0;
+			margin: 0;
+		}
+
+		> .button {
+			position: relative;
+			display: inline-block;
+			flex-shrink: 0;
+			margin: 3px 0 0 0;
+			width: 34px;
+			height: 14px;
+			background: var(--X6);
+			outline: none;
+			border-radius: 14px;
+			transition: all 0.3s;
+			cursor: pointer;
+
+			> * {
+				position: absolute;
+				top: -3px;
+				left: 0;
+				border-radius: 100%;
+				transition: background-color 0.3s, transform 0.3s;
+				width: 20px;
+				height: 20px;
+				background-color: #fff;
+				box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12);
+			}
+		}
+
+		> .label {
+			margin-left: 12px;
+			display: block;
+			transition: inherit;
+			color: var(--fg);
+
+			> span {
+				display: block;
+				line-height: 20px;
+				transition: inherit;
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue
new file mode 100644
index 0000000000..d84b48197a
--- /dev/null
+++ b/src/client/components/form/textarea.vue
@@ -0,0 +1,136 @@
+<template>
+<div class="rivhosbp _formItem" :class="{ tall, pre }">
+	<div class="_formLabel"><slot></slot></div>
+	<div class="input _formPanel">
+		<textarea ref="input" :class="{ code, _monospace: code }"
+			:value="value"
+			:required="required"
+			:readonly="readonly"
+			:pattern="pattern"
+			:autocomplete="autocomplete"
+			:spellcheck="!code"
+			@input="onInput"
+			@focus="focused = true"
+			@blur="focused = false"
+		></textarea>
+	</div>
+	<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button>
+	<div class="_formCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './form.scss';
+
+export default defineComponent({
+	props: {
+		value: {
+			required: false
+		},
+		required: {
+			type: Boolean,
+			required: false
+		},
+		readonly: {
+			type: Boolean,
+			required: false
+		},
+		pattern: {
+			type: String,
+			required: false
+		},
+		autocomplete: {
+			type: String,
+			required: false
+		},
+		code: {
+			type: Boolean,
+			required: false
+		},
+		tall: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		pre: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		save: {
+			type: Function,
+			required: false,
+		},
+	},
+	data() {
+		return {
+			changed: false,
+		}
+	},
+	methods: {
+		focus() {
+			this.$refs.input.focus();
+		},
+		onInput(ev) {
+			this.changed = true;
+			this.$emit('update:value', ev.target.value);
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.rivhosbp {
+	position: relative;
+
+	> .input {
+		position: relative;
+	
+		> textarea {
+			display: block;
+			width: 100%;
+			min-width: 100%;
+			max-width: 100%;
+			min-height: 130px;
+			margin: 0;
+			padding: 16px;
+			box-sizing: border-box;
+			font: inherit;
+			font-weight: normal;
+			font-size: 1em;
+			background: transparent;
+			border: none;
+			border-radius: 0;
+			outline: none;
+			box-shadow: none;
+			color: var(--fg);
+
+			&.code {
+				tab-size: 2;
+			}
+		}
+	}
+
+	> .save {
+		margin: 6px 0 0 0;
+		font-size: 0.8em;
+	}
+
+	&.tall {
+		> .input {
+			> textarea {
+				min-height: 200px;
+			}
+		}
+	}
+
+	&.pre {
+		> .input {
+			> textarea {
+				white-space: pre;
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/form/tuple.vue b/src/client/components/form/tuple.vue
new file mode 100644
index 0000000000..6c8a22d189
--- /dev/null
+++ b/src/client/components/form/tuple.vue
@@ -0,0 +1,36 @@
+<template>
+<div class="wthhikgt _formItem" v-size="{ max: [500] }">
+	<slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+});
+</script>
+
+<style lang="scss" scoped>
+.wthhikgt {
+	position: relative;
+	display: flex;
+
+	> ::v-deep(*) {
+		flex: 1;
+		margin: 0;
+
+		&:not(:last-child) {
+			margin-right: 16px;
+		}
+	}
+
+	&.max-width_500px {
+		display: block;
+
+		> ::v-deep(*) {
+			margin: inherit;
+		}
+	}
+}
+</style>
diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue
index 64e3efab31..a9d0023cc2 100644
--- a/src/client/components/media-image.vue
+++ b/src/client/components/media-image.vue
@@ -68,7 +68,7 @@ export default defineComponent({
 	created() {
 		// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
 		this.$watch('image', () => {
-			this.hide = this.image.isSensitive && !this.$store.state.device.alwaysShowNsfw;
+			this.hide = (this.$store.state.device.nsfw === 'force') ? true : this.image.isSensitive && (this.$store.state.device.nsfw !== 'ignore');
 			if (this.image.blurhash) {
 				this.color = extractAvgColorFromBlurhash(this.image.blurhash);
 			}
diff --git a/src/client/components/media-video.vue b/src/client/components/media-video.vue
index 21faddf73b..3dfd60c87f 100644
--- a/src/client/components/media-video.vue
+++ b/src/client/components/media-video.vue
@@ -48,7 +48,7 @@ export default defineComponent({
 		}
 	},
 	created() {
-		this.hide = this.video.isSensitive && !this.$store.state.device.alwaysShowNsfw;
+		this.hide = (this.$store.state.device.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.device.nsfw !== 'ignore');
 	},
 });
 </script>
diff --git a/src/client/components/taskmanager.api-window.vue b/src/client/components/taskmanager.api-window.vue
index 0df3f75fa2..ec685462c9 100644
--- a/src/client/components/taskmanager.api-window.vue
+++ b/src/client/components/taskmanager.api-window.vue
@@ -14,8 +14,8 @@
 			<option value="res">Response</option>
 		</MkTab>
 
-		<code v-if="tab === 'req'">{{ reqStr }}</code>
-		<code v-if="tab === 'res'">{{ resStr }}</code>
+		<code v-if="tab === 'req'" class="_monospace">{{ reqStr }}</code>
+		<code v-if="tab === 'res'" class="_monospace">{{ resStr }}</code>
 	</div>
 </XWindow>
 </template>
@@ -67,7 +67,6 @@ export default defineComponent({
 		font-size: 0.9em;
 		tab-size: 2;
 		white-space: pre;
-		font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
 	}
 }
 </style>
diff --git a/src/client/components/taskmanager.vue b/src/client/components/taskmanager.vue
index 92c56442c3..1ed8c8bd5e 100644
--- a/src/client/components/taskmanager.vue
+++ b/src/client/components/taskmanager.vue
@@ -3,7 +3,7 @@
 	<template #header>
 		<Fa :icon="faTerminal" style="margin-right: 0.5em;"/>Task Manager
 	</template>
-	<div class="qljqmnzj">
+	<div class="qljqmnzj _monospace">
 		<MkTab v-model:value="tab" style="border-bottom: solid 1px var(--divider);">
 			<option value="windows">Windows</option>
 			<option value="stream">Stream</option>
@@ -150,7 +150,6 @@ export default defineComponent({
 	display: flex;
 	flex-direction: column;
 	height: 100%;
-	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
 
 	> .content {
 		flex: 1;
diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue
index 930f47b1a5..df9424d8ed 100644
--- a/src/client/components/timeline.vue
+++ b/src/client/components/timeline.vue
@@ -6,6 +6,7 @@
 import { defineComponent } from 'vue';
 import XNotes from './notes.vue';
 import * as os from '@/os';
+import * as sound from '@/scripts/sound';
 
 export default defineComponent({
 	components: {
@@ -65,7 +66,7 @@ export default defineComponent({
 			this.$emit('note');
 
 			if (this.sound) {
-				os.sound(note.userId === this.$store.state.i.id ? 'noteMy' : 'note');
+				sound.play(note.userId === this.$store.state.i.id ? 'noteMy' : 'note');
 			}
 		};
 
diff --git a/src/client/components/ui/range.vue b/src/client/components/ui/range.vue
index c6e585cf50..4cfe66a8fc 100644
--- a/src/client/components/ui/range.vue
+++ b/src/client/components/ui/range.vue
@@ -1,7 +1,7 @@
 <template>
 <div class="timctyfi" :class="{ focused, disabled }">
 	<div class="icon"><slot name="icon"></slot></div>
-	<span class="title"><slot name="title"></slot></span>
+	<span class="label"><slot name="label"></slot></span>
 	<input
 		type="range"
 		ref="input"
@@ -19,7 +19,7 @@
 </template>
 
 <script lang="ts">
-import { defineComponent } from 'vue';import * as os from '@/os';
+import { defineComponent } from 'vue';
 
 export default defineComponent({
 	props: {
diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue
index f738257232..762fba6d99 100644
--- a/src/client/components/ui/switch.vue
+++ b/src/client/components/ui/switch.vue
@@ -17,10 +17,8 @@
 		<span></span>
 	</span>
 	<span class="label">
-		<span :aria-hidden="!checked"><slot></slot></span>
-		<p :aria-hidden="!checked">
-			<slot name="desc"></slot>
-		</p>
+		<span><slot></slot></span>
+		<p><slot name="desc"></slot></p>
 	</span>
 </div>
 </template>
diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue
index 7d3250cc45..d49d7e8342 100644
--- a/src/client/components/ui/textarea.vue
+++ b/src/client/components/ui/textarea.vue
@@ -2,7 +2,7 @@
 <div class="adhpbeos" :class="{ focused, filled, tall, pre }">
 	<div class="input">
 		<span class="label" ref="label"><slot></slot></span>
-		<textarea ref="input" :class="{ code }"
+		<textarea ref="input" :class="{ code, _monospace: code }"
 			:value="value"
 			:required="required"
 			:readonly="readonly"
@@ -166,7 +166,6 @@ export default defineComponent({
 
 			&.code {
 				tab-size: 2;
-				font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
 			}
 		}
 	}
diff --git a/src/client/init.ts b/src/client/init.ts
index cc97947c0a..9294733bbb 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -16,7 +16,8 @@ import { router } from './router';
 import { applyTheme } from '@/scripts/theme';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
 import { i18n, lang } from './i18n';
-import { stream, sound, isMobile, dialog } from '@/os';
+import { stream, isMobile, dialog } from '@/os';
+import * as sound from './scripts/sound';
 
 console.info(`Misskey v${version}`);
 
@@ -50,7 +51,7 @@ if (_DEV_) {
 document.addEventListener('touchend', () => {}, { passive: true });
 
 if (localStorage.getItem('theme') == null) {
-	applyTheme(require('@/themes/l-white.json5'));
+	applyTheme(require('@/themes/l-light.json5'));
 }
 
 //#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
@@ -307,7 +308,7 @@ if (store.getters.isSignedIn) {
 			hasUnreadMessagingMessage: true
 		});
 
-		sound('chatBg');
+		sound.play('chatBg');
 	});
 
 	main.on('readAllAntennas', () => {
@@ -321,7 +322,7 @@ if (store.getters.isSignedIn) {
 			hasUnreadAntenna: true
 		});
 
-		sound('antenna');
+		sound.play('antenna');
 	});
 
 	main.on('readAllAnnouncements', () => {
@@ -341,7 +342,7 @@ if (store.getters.isSignedIn) {
 			hasUnreadChannel: true
 		});
 
-		sound('channel');
+		sound.play('channel');
 	});
 
 	main.on('readAllAnnouncements', () => {
diff --git a/src/client/os.ts b/src/client/os.ts
index 88d445ebac..d43de4de44 100644
--- a/src/client/os.ts
+++ b/src/client/os.ts
@@ -6,6 +6,7 @@ import { apiUrl, debug } from '@/config';
 import MkPostFormDialog from '@/components/post-form-dialog.vue';
 import MkWaitingDialog from '@/components/waiting-dialog.vue';
 import { resolve } from '@/router';
+import { device } from './cold-storage';
 
 const ua = navigator.userAgent.toLowerCase();
 export const isMobile = /mobile|iphone|ipad|android/.test(ua);
@@ -344,15 +345,6 @@ export function post(props: Record<string, any>) {
 	});
 }
 
-export function sound(type: string) {
-	if (store.state.device.sfxVolume === 0) return;
-	const sound = store.state.device['sfx' + type.substr(0, 1).toUpperCase() + type.substr(1)];
-	if (sound == null) return;
-	const audio = new Audio(`/assets/sounds/${sound}.mp3`);
-	audio.volume = store.state.device.sfxVolume;
-	audio.play();
-}
-
 export const deckGlobalEvents = new EventEmitter();
 
 export const uploads = ref([]);
diff --git a/src/client/pages/announcements.vue b/src/client/pages/announcements.vue
index a202ec749f..50dc994025 100644
--- a/src/client/pages/announcements.vue
+++ b/src/client/pages/announcements.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="_section">
-	<MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content" ref="list">
+	<MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content">
 		<section class="_card announcement _vMargin" v-for="(announcement, i) in items" :key="announcement.id">
 			<div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
 			<div class="_content">
diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue
index 32a6a9595f..542c2942b9 100644
--- a/src/client/pages/instance/settings.vue
+++ b/src/client/pages/instance/settings.vue
@@ -7,6 +7,8 @@
 			<MkTextarea v-model:value="description">{{ $t('instanceDescription') }}</MkTextarea>
 			<MkInput v-model:value="iconUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('iconUrl') }}</MkInput>
 			<MkInput v-model:value="bannerUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</MkInput>
+			<MkInput v-model:value="backgroundImageUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('backgroundImageUrl') }}</MkInput>
+			<MkInput v-model:value="logoImageUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('logoImageUrl') }}</MkInput>
 			<MkInput v-model:value="tosUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('tosUrl') }}</MkInput>
 			<MkInput v-model:value="maintainerName">{{ $t('maintainerName') }}</MkInput>
 			<MkInput v-model:value="maintainerEmail" type="email"><template #icon><Fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</MkInput>
@@ -292,6 +294,8 @@ export default defineComponent({
 			email: null,
 			bannerUrl: null,
 			iconUrl: null,
+			logoImageUrl: null,
+			backgroundImageUrl: null,
 			maxNoteTextLength: 0,
 			enableRegistration: false,
 			enableLocalTimeline: false,
@@ -345,6 +349,8 @@ export default defineComponent({
 		this.tosUrl = this.meta.tosUrl;
 		this.bannerUrl = this.meta.bannerUrl;
 		this.iconUrl = this.meta.iconUrl;
+		this.logoImageUrl = this.meta.logoImageUrl;
+		this.backgroundImageUrl = this.meta.backgroundImageUrl;
 		this.enableEmail = this.meta.enableEmail;
 		this.email = this.meta.email;
 		this.maintainerName = this.meta.maintainerName;
@@ -498,6 +504,8 @@ export default defineComponent({
 				tosUrl: this.tosUrl,
 				bannerUrl: this.bannerUrl,
 				iconUrl: this.iconUrl,
+				logoImageUrl: this.logoImageUrl,
+				backgroundImageUrl: this.backgroundImageUrl,
 				maintainerName: this.maintainerName,
 				maintainerEmail: this.maintainerEmail,
 				maxNoteTextLength: this.maxNoteTextLength,
diff --git a/src/client/pages/messaging/messaging-room.vue b/src/client/pages/messaging/messaging-room.vue
index f414ccbaa4..d4331c1390 100644
--- a/src/client/pages/messaging/messaging-room.vue
+++ b/src/client/pages/messaging/messaging-room.vue
@@ -38,6 +38,7 @@ import parseAcct from '../../../misc/acct/parse';
 import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
 import * as os from '@/os';
 import { popout } from '@/scripts/popout';
+import * as sound from '@/scripts/sound';
 
 const Component = defineComponent({
 	components: {
@@ -218,7 +219,7 @@ const Component = defineComponent({
 		},
 
 		onMessage(message) {
-			os.sound('chat');
+			sound.play('chat');
 
 			const _isBottom = isBottom(this.$el, 64);
 
diff --git a/src/client/pages/reversi/game.board.vue b/src/client/pages/reversi/game.board.vue
index 6559396aca..302d7bc79c 100644
--- a/src/client/pages/reversi/game.board.vue
+++ b/src/client/pages/reversi/game.board.vue
@@ -94,6 +94,7 @@ import { url } from '@/config';
 import MkButton from '@/components/ui/button.vue';
 import { userPage } from '@/filters/user';
 import * as os from '@/os';
+import * as sound from '@/scripts/sound';
 
 export default defineComponent({
 	components: {
@@ -245,11 +246,7 @@ export default defineComponent({
 			this.o.put(this.myColor, pos);
 
 			// サウンドを再生する
-			if (this.$store.state.device.enableSounds) {
-				const sound = new Audio(`${url}/assets/reversi-put-me.mp3`);
-				sound.volume = this.$store.state.device.soundVolume;
-				sound.play();
-			}
+			sound.play(this.myColor ? 'reversiPutBlack' : 'reversiPutWhite');
 
 			this.connection.send('set', {
 				pos: pos
@@ -268,10 +265,8 @@ export default defineComponent({
 			this.$forceUpdate();
 
 			// サウンドを再生する
-			if (this.$store.state.device.enableSounds && x.color != this.myColor) {
-				const sound = new Audio(`${url}/assets/reversi-put-you.mp3`);
-				sound.volume = this.$store.state.device.soundVolume;
-				sound.play();
+			if (x.color !== this.myColor) {
+				sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
 			}
 		},
 
diff --git a/src/client/pages/settings/security.2fa.vue b/src/client/pages/settings/2fa.vue
similarity index 96%
rename from src/client/pages/settings/security.2fa.vue
rename to src/client/pages/settings/2fa.vue
index 22b3878445..dc6d12a40f 100644
--- a/src/client/pages/settings/security.2fa.vue
+++ b/src/client/pages/settings/2fa.vue
@@ -75,14 +75,25 @@ import MkButton from '@/components/ui/button.vue';
 import MkInfo from '@/components/ui/info.vue';
 import MkInput from '@/components/ui/input.vue';
 import MkSwitch from '@/components/ui/switch.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
+		FormBase,
 		MkButton, MkInfo, MkInput, MkSwitch
 	},
+
+	emits: ['info'],
+
 	data() {
 		return {
+			INFO: {
+				title: this.$t('twoStepAuthentication'),
+				icon: faLock
+			},
 			data: null,
 			supportsCredentials: !!navigator.credentials,
 			usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
@@ -92,6 +103,7 @@ export default defineComponent({
 			faLock
 		};
 	},
+
 	methods: {
 		register() {
 			os.dialog({
@@ -225,6 +237,7 @@ export default defineComponent({
 				});
 			});
 		},
+
 		updatePasswordLessLogin() {
 			os.api('i/2fa/password-less', {
 				value: !!this.usePasswordLessLogin
diff --git a/src/client/pages/settings/account-info.vue b/src/client/pages/settings/account-info.vue
new file mode 100644
index 0000000000..c881b91535
--- /dev/null
+++ b/src/client/pages/settings/account-info.vue
@@ -0,0 +1,185 @@
+<template>
+<FormBase>
+	<FormKeyValueView>
+		<template #key>ID</template>
+		<template #value><span class="_monospace">{{ $store.state.i.id }}</span></template>
+	</FormKeyValueView>
+
+	<FormGroup>
+		<FormKeyValueView>
+			<template #key>{{ $t('registeredDate') }}</template>
+			<template #value><MkTime :time="$store.state.i.createdAt" mode="detail"/></template>
+		</FormKeyValueView>
+	</FormGroup>
+
+	<FormGroup v-if="stats">
+		<template #label>{{ $t('statistics') }}</template>
+		<FormKeyValueView>
+			<template #key>{{ $t('notesCount') }}</template>
+			<template #value>{{ number(stats.notesCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('repliesCount') }}</template>
+			<template #value>{{ number(stats.repliesCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('renotesCount') }}</template>
+			<template #value>{{ number(stats.renotesCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('repliedCount') }}</template>
+			<template #value>{{ number(stats.repliedCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('renotedCount') }}</template>
+			<template #value>{{ number(stats.renotedCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('pollVotesCount') }}</template>
+			<template #value>{{ number(stats.pollVotesCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('pollVotedCount') }}</template>
+			<template #value>{{ number(stats.pollVotedCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('sentReactionsCount') }}</template>
+			<template #value>{{ number(stats.sentReactionsCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('receivedReactionsCount') }}</template>
+			<template #value>{{ number(stats.receivedReactionsCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('noteFavoritesCount') }}</template>
+			<template #value>{{ number(stats.noteFavoritesCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('followingCount') }}</template>
+			<template #value>{{ number(stats.followingCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('followingCount') }} ({{ $t('local') }})</template>
+			<template #value>{{ number(stats.localFollowingCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('followingCount') }} ({{ $t('remote') }})</template>
+			<template #value>{{ number(stats.remoteFollowingCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('followersCount') }}</template>
+			<template #value>{{ number(stats.followersCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('followersCount') }} ({{ $t('local') }})</template>
+			<template #value>{{ number(stats.localFollowersCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('followersCount') }} ({{ $t('remote') }})</template>
+			<template #value>{{ number(stats.remoteFollowersCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('pageLikesCount') }}</template>
+			<template #value>{{ number(stats.pageLikesCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('pageLikedCount') }}</template>
+			<template #value>{{ number(stats.pageLikedCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('driveFilesCount') }}</template>
+			<template #value>{{ number(stats.driveFilesCount) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('driveUsage') }}</template>
+			<template #value>{{ bytes(stats.driveUsage) }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>{{ $t('reversiCount') }}</template>
+			<template #value>{{ number(stats.reversiCount) }}</template>
+		</FormKeyValueView>
+	</FormGroup>
+
+	<FormGroup>
+		<template #label>{{ $t('other') }}</template>
+		<FormKeyValueView>
+			<template #key>emailVerified</template>
+			<template #value>{{ $store.state.i.emailVerified ? $t('yes') : $t('no') }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>twoFactorEnabled</template>
+			<template #value>{{ $store.state.i.twoFactorEnabled ? $t('yes') : $t('no') }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>securityKeys</template>
+			<template #value>{{ $store.state.i.securityKeys ? $t('yes') : $t('no') }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>usePasswordLessLogin</template>
+			<template #value>{{ $store.state.i.usePasswordLessLogin ? $t('yes') : $t('no') }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>isModerator</template>
+			<template #value>{{ $store.state.i.isModerator ? $t('yes') : $t('no') }}</template>
+		</FormKeyValueView>
+		<FormKeyValueView>
+			<template #key>isAdmin</template>
+			<template #value>{{ $store.state.i.isAdmin ? $t('yes') : $t('no') }}</template>
+		</FormKeyValueView>
+	</FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+
+export default defineComponent({
+	components: {
+		FormBase,
+		FormSelect,
+		FormSwitch,
+		FormButton,
+		FormLink,
+		FormGroup,
+		FormKeyValueView,
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$t('accountInfo'),
+				icon: faInfoCircle
+			},
+			stats: null
+		}
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+
+		os.api('users/stats', {
+			userId: this.$store.state.i.id
+		}).then(stats => {
+			this.stats = stats;
+		});
+	},
+
+	methods: {
+		number,
+		bytes,
+	}
+});
+</script>
diff --git a/src/client/pages/settings/api.vue b/src/client/pages/settings/api.vue
index f4cebbee36..8e5e4fbc66 100644
--- a/src/client/pages/settings/api.vue
+++ b/src/client/pages/settings/api.vue
@@ -1,26 +1,27 @@
 <template>
-<div>
-	<div class="_section">
-		<div class="_content">
-			<MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton>
-		</div>
-	</div>
-	<div class="_section">
-		<MkA to="/api-console" :behavior="isDesktop ? 'window' : null">API console</MkA>
-	</div>
-</div>
+<FormBase>
+	<FormButton @click="generateToken" primary>{{ $t('generateAccessToken') }}</FormButton>
+	<FormLink to="/settings/apps">{{ $t('manageAccessTokens') }}</FormLink>
+	<FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faKey } from '@fortawesome/free-solid-svg-icons';
-import MkButton from '@/components/ui/button.vue';
-import MkInput from '@/components/ui/input.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkButton, MkInput
+		FormBase,
+		FormButton,
+		FormLink,
 	},
 
 	emits: ['info'],
diff --git a/src/client/pages/apps.vue b/src/client/pages/settings/apps.vue
similarity index 63%
rename from src/client/pages/apps.vue
rename to src/client/pages/settings/apps.vue
index f9dd0a3584..724a2e8d1f 100644
--- a/src/client/pages/apps.vue
+++ b/src/client/pages/settings/apps.vue
@@ -1,6 +1,6 @@
 <template>
-<div>
-	<MkPagination :pagination="pagination" class="bfomjevm" ref="list">
+<FormBase>
+	<FormPagination :pagination="pagination" ref="list">
 		<template #empty>
 			<div class="_fullinfo">
 				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
@@ -8,8 +8,8 @@
 			</div>
 		</template>
 		<template #default="{items}">
-			<div class="token _panel" v-for="token in items" :key="token.id">
-				<img class="icon" :src="token.iconUrl" alt=""/>
+			<div class="_formPanel bfomjevm" v-for="token in items" :key="token.id">
+				<img class="icon" :src="token.iconUrl" alt="" v-if="token.iconUrl"/>
 				<div class="body">
 					<div class="name">{{ token.name }}</div>
 					<div class="description">{{ token.description }}</div>
@@ -33,21 +33,29 @@
 				</div>
 			</div>
 		</template>
-	</MkPagination>
-</div>
+	</FormPagination>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faTrashAlt, faPlug } from '@fortawesome/free-solid-svg-icons';
-import MkPagination from '@/components/ui/pagination.vue';
+import FormPagination from '@/components/form/pagination.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkPagination
+		FormBase,
+		FormPagination,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
 			INFO: {
@@ -65,6 +73,10 @@ export default defineComponent({
 		};
 	},
 
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
 	methods: {
 		revoke(token) {
 			os.api('i/revoke-token', { tokenId: token.id }).then(() => {
@@ -77,26 +89,24 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .bfomjevm {
-	> .token {
-		display: flex;
-		padding: 16px;
+	display: flex;
+	padding: 16px;
 
-		> .icon {
-			display: block;
-			flex-shrink: 0;
-			margin: 0 12px 0 0;
-			width: 50px;
-			height: 50px;
-			border-radius: 8px;
-		}
+	> .icon {
+		display: block;
+		flex-shrink: 0;
+		margin: 0 12px 0 0;
+		width: 50px;
+		height: 50px;
+		border-radius: 8px;
+	}
 
-		> .body {
-			width: calc(100% - 62px);
-			position: relative;
+	> .body {
+		width: calc(100% - 62px);
+		position: relative;
 
-			> .name {
-				font-weight: bold;
-			}
+		> .name {
+			font-weight: bold;
 		}
 	}
 }
diff --git a/src/client/pages/settings/deck.vue b/src/client/pages/settings/deck.vue
new file mode 100644
index 0000000000..2eb2f60cba
--- /dev/null
+++ b/src/client/pages/settings/deck.vue
@@ -0,0 +1,90 @@
+<template>
+<FormBase>
+
+	<section class="_card _vMargin">
+		<div class="_title"><Fa :icon="faColumns"/> </div>
+		<div class="_content">
+			<div>{{ $t('defaultNavigationBehaviour') }}</div>
+			<MkSwitch v-model:value="deckNavWindow">{{ $t('openInWindow') }}</MkSwitch>
+		</div>
+		<div class="_content">
+			<MkSwitch v-model:value="deckAlwaysShowMainColumn">
+				{{ $t('_deck.alwaysShowMainColumn') }}
+			</MkSwitch>
+		</div>
+		<div class="_content">
+			<div>{{ $t('_deck.columnAlign') }}</div>
+			<MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio>
+			<MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio>
+		</div>
+	</section>
+
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faImage, faCog, faColumns } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/ui/switch.vue';
+import MkSelect from '@/components/ui/select.vue';
+import MkRadio from '@/components/ui/radio.vue';
+import MkRadios from '@/components/ui/radios.vue';
+import MkRange from '@/components/ui/range.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import { clientDb, set } from '@/db';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkSwitch,
+		MkSelect,
+		MkRadio,
+		MkRadios,
+		MkRange,
+		FormSwitch,
+		FormSelect,
+		FormRadios,
+		FormBase,
+		FormGroup,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			INFO: {
+				title: this.$t('deck'),
+				icon: faColumns
+			},
+			faImage, faCog,
+		}
+	},
+
+	computed: {
+		deckNavWindow: {
+			get() { return this.$store.state.device.deckNavWindow; },
+			set(value) { this.$store.commit('device/set', { key: 'deckNavWindow', value }); }
+		},
+
+		deckAlwaysShowMainColumn: {
+			get() { return this.$store.state.device.deckAlwaysShowMainColumn; },
+			set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); }
+		},
+
+		deckColumnAlign: {
+			get() { return this.$store.state.device.deckColumnAlign; },
+			set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); }
+		},
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+});
+</script>
diff --git a/src/client/pages/settings/email-address.vue b/src/client/pages/settings/email-address.vue
new file mode 100644
index 0000000000..7ff89d7910
--- /dev/null
+++ b/src/client/pages/settings/email-address.vue
@@ -0,0 +1,71 @@
+<template>
+<FormBase>
+	<FormGroup>
+		<FormInput v-model:value="emailAddress" type="email">
+			{{ $t('emailAddress') }}
+			<template #desc v-if="$store.state.i.email && !$store.state.i.emailVerified">{{ $t('verificationEmailSent') }}</template>
+			<template #desc v-else-if="emailAddress === $store.state.i.email && $store.state.i.emailVerified">{{ $t('emailVerified') }}</template>
+		</FormInput>
+	</FormGroup>
+	<FormButton @click="save" primary>{{ $t('save') }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faCog } from '@fortawesome/free-solid-svg-icons';
+import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
+import FormButton from '@/components/form/button.vue';
+import FormInput from '@/components/form/input.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		FormBase,
+		FormInput,
+		FormButton,
+		FormGroup,
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$t('emailAddress'),
+				icon: faEnvelope
+			},
+			emailAddress: null,
+			code: null,
+			faCog
+		}
+	},
+
+	created() {
+		this.emailAddress = this.$store.state.i.email;
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
+	methods: {
+		save() {
+			os.dialog({
+				title: this.$t('password'),
+				input: {
+					type: 'password'
+				}
+			}).then(({ canceled, result: password }) => {
+				if (canceled) return;
+				os.api('i/update-email', {
+					password: password,
+					email: this.emailAddress,
+				});
+			});
+		}
+	}
+});
+</script>
diff --git a/src/client/pages/settings/email.vue b/src/client/pages/settings/email.vue
new file mode 100644
index 0000000000..f72ee29a97
--- /dev/null
+++ b/src/client/pages/settings/email.vue
@@ -0,0 +1,52 @@
+<template>
+<FormBase>
+	<FormGroup>
+		<template #label>{{ $t('emailAddress') }}</template>
+		<FormLink to="/settings/email/address">
+			<template v-if="$store.state.i.email && !$store.state.i.emailVerified" #icon><Fa :icon="faExclamationTriangle" style="color: var(--warn);"/></template>
+			<template v-else-if="$store.state.i.email && $store.state.i.emailVerified" #icon><Fa :icon="faCheck" style="color: var(--success);"/></template>
+			{{ $store.state.i.email || $t('notSet') }}
+		</FormLink>
+	</FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faCog, faExclamationTriangle, faCheck } from '@fortawesome/free-solid-svg-icons';
+import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
+import FormButton from '@/components/form/button.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		FormBase,
+		FormLink,
+		FormButton,
+		FormGroup,
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$t('email'),
+				icon: faEnvelope
+			},
+			faCog, faExclamationTriangle, faCheck
+		}
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
+	methods: {
+
+	}
+});
+</script>
diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue
index c88c573ae6..7c2905fdeb 100644
--- a/src/client/pages/settings/general.vue
+++ b/src/client/pages/settings/general.vue
@@ -1,109 +1,110 @@
 <template>
-<div class="">
-	<section class="_card _vMargin">
-		<div class="_title"><Fa :icon="faCog"/> {{ $t('general') }}</div>
-		<div class="_content">
-			<MkRadios v-model="serverDisconnectedBehavior">
-				<template #desc>{{ $t('whenServerDisconnected') }}</template>
-				<option value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</option>
-				<option value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</option>
-				<option value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</option>
-			</MkRadios>
-			<MkSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</MkSwitch>
-			<MkSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</MkSwitch>
-			<MkSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</MkSwitch>
-			<MkSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</MkSwitch>
-			<MkSelect v-model:value="lang">
-				<template #label>{{ $t('uiLanguage') }}</template>
-				<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
-			</MkSelect>
-		</div>
-	</section>
+<FormBase>
+	<FormSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</FormSwitch>
 
-	<section class="_card _vMargin">
-		<div class="_title"><Fa :icon="faCog"/> {{ $t('defaultNavigationBehaviour') }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="defaultSideView">{{ $t('openInSideView') }}</MkSwitch>
-		</div>
-		<div class="_content">
-			<MkRadios v-model="chatOpenBehavior">
-				<template #desc>{{ $t('chatOpenBehavior') }}</template>
-				<option value="page">{{ $t('showInPage') }}</option>
-				<option value="window">{{ $t('openInWindow') }}</option>
-				<option value="popout">{{ $t('popout') }}</option>
-			</MkRadios>
-		</div>
-	</section>
+	<FormSelect v-model:value="lang">
+		<template #label>{{ $t('uiLanguage') }}</template>
+		<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+		<template #caption>
+			<i18n-t keypath="i18nInfo" tag="span">
+				<template #link>
+					<MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink>
+				</template>
+			</i18n-t>
+		</template>
+	</FormSelect>
 
-	<section class="_card _vMargin">
-		<div class="_title"><Fa :icon="faCog"/> {{ $t('appearance') }}</div>
-		<div class="_content">
-			<MkSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</MkSwitch>
-			<MkSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</MkSwitch>
-			<MkSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</MkSwitch>
-			<MkSwitch v-model:value="useOsNativeEmojis">
-				{{ $t('useOsNativeEmojis') }}
-				<template #desc><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template>
-			</MkSwitch>
-			<MkRadios v-model="fontSize">
-				<template #desc>{{ $t('fontSize') }}</template>
-				<option value="small"><span style="font-size: 14px;">Aa</span></option>
-				<option :value="null"><span style="font-size: 16px;">Aa</span></option>
-				<option value="large"><span style="font-size: 18px;">Aa</span></option>
-				<option value="veryLarge"><span style="font-size: 20px;">Aa</span></option>
-			</MkRadios>
-			<MkRadios v-model="instanceTicker">
-				<template #desc>{{ $t('instanceTicker') }}</template>
-				<option value="none">{{ $t('_instanceTicker.none') }}</option>
-				<option value="remote">{{ $t('_instanceTicker.remote') }}</option>
-				<option value="always">{{ $t('_instanceTicker.always') }}</option>
-			</MkRadios>
-		</div>
-	</section>
+	<FormGroup>
+		<template #label>{{ $t('behavior') }}</template>
+		<FormSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</FormSwitch>
+		<FormSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</FormSwitch>
+		<FormSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</FormSwitch>
+	</FormGroup>
 
-	<section class="_card _vMargin">
-		<div class="_title"><Fa :icon="faColumns"/> {{ $t('deck') }}</div>
-		<div class="_content">
-			<div>{{ $t('defaultNavigationBehaviour') }}</div>
-			<MkSwitch v-model:value="deckNavWindow">{{ $t('openInWindow') }}</MkSwitch>
-		</div>
-		<div class="_content">
-			<MkSwitch v-model:value="deckAlwaysShowMainColumn">
-				{{ $t('_deck.alwaysShowMainColumn') }}
-			</MkSwitch>
-		</div>
-		<div class="_content">
-			<div>{{ $t('_deck.columnAlign') }}</div>
-			<MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio>
-			<MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio>
-		</div>
-	</section>
+	<FormSelect v-model:value="serverDisconnectedBehavior">
+		<template #label>{{ $t('whenServerDisconnected') }}</template>
+		<option value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</option>
+		<option value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</option>
+		<option value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</option>
+	</FormSelect>
 
-	<MkButton @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</MkButton>
-</div>
+	<FormGroup>
+		<template #label>{{ $t('appearance') }}</template>
+		<FormSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</FormSwitch>
+		<FormSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</FormSwitch>
+		<FormSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</FormSwitch>
+		<FormSwitch v-model:value="useOsNativeEmojis">{{ $t('useOsNativeEmojis') }}
+			<div><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
+		</FormSwitch>
+		<FormSwitch v-model:value="loadRawImages">{{ $t('loadRawImages') }}</FormSwitch>
+		<FormSwitch v-model:value="disableShowingAnimatedImages">{{ $t('disableShowingAnimatedImages') }}</FormSwitch>
+	</FormGroup>
+
+	<FormRadios v-model="fontSize">
+		<template #desc>{{ $t('fontSize') }}</template>
+		<option value="small"><span style="font-size: 14px;">Aa</span></option>
+		<option :value="null"><span style="font-size: 16px;">Aa</span></option>
+		<option value="large"><span style="font-size: 18px;">Aa</span></option>
+		<option value="veryLarge"><span style="font-size: 20px;">Aa</span></option>
+	</FormRadios>
+
+	<FormSelect v-model:value="instanceTicker">
+		<template #label>{{ $t('instanceTicker') }}</template>
+		<option value="none">{{ $t('_instanceTicker.none') }}</option>
+		<option value="remote">{{ $t('_instanceTicker.remote') }}</option>
+		<option value="always">{{ $t('_instanceTicker.always') }}</option>
+	</FormSelect>
+
+	<FormSelect v-model:value="nsfw">
+		<template #label>{{ $t('nsfw') }}</template>
+		<option value="respect">{{ $t('_nsfw.respect') }}</option>
+		<option value="ignore">{{ $t('_nsfw.ignore') }}</option>
+		<option value="force">{{ $t('_nsfw.force') }}</option>
+	</FormSelect>
+
+	<FormGroup>
+		<template #label>{{ $t('defaultNavigationBehaviour') }}</template>
+		<FormSwitch v-model:value="defaultSideView">{{ $t('openInSideView') }}</FormSwitch>
+	</FormGroup>
+
+	<FormSelect v-model:value="chatOpenBehavior">
+		<template #label>{{ $t('chatOpenBehavior') }}</template>
+		<option value="page">{{ $t('showInPage') }}</option>
+		<option value="window">{{ $t('openInWindow') }}</option>
+		<option value="popout">{{ $t('popout') }}</option>
+	</FormSelect>
+
+	<FormLink to="/settings/deck">{{ $t('deck') }}</FormLink>
+
+	<FormButton @click="cacheClear()" danger>{{ $t('cacheClear') }}</FormButton>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faImage, faCog, faColumns, faCogs } from '@fortawesome/free-solid-svg-icons';
-import MkButton from '@/components/ui/button.vue';
-import MkSwitch from '@/components/ui/switch.vue';
-import MkSelect from '@/components/ui/select.vue';
-import MkRadio from '@/components/ui/radio.vue';
-import MkRadios from '@/components/ui/radios.vue';
-import MkRange from '@/components/ui/range.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormLink from '@/components/form/link.vue';
+import FormButton from '@/components/form/button.vue';
+import MkLink from '@/components/link.vue';
 import { langs } from '@/config';
 import { clientDb, set } from '@/db';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkSwitch,
-		MkSelect,
-		MkRadio,
-		MkRadios,
-		MkRange,
+		MkLink,
+		FormSwitch,
+		FormSelect,
+		FormRadios,
+		FormBase,
+		FormGroup,
+		FormLink,
+		FormButton,
 	},
 
 	emits: ['info'],
@@ -167,11 +168,6 @@ export default defineComponent({
 			set(value) { this.$store.commit('device/set', { key: 'defaultSideView', value }); }
 		},
 
-		deckNavWindow: {
-			get() { return this.$store.state.device.deckNavWindow; },
-			set(value) { this.$store.commit('device/set', { key: 'deckNavWindow', value }); }
-		},
-
 		chatOpenBehavior: {
 			get() { return this.$store.state.device.chatOpenBehavior; },
 			set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); }
@@ -182,20 +178,25 @@ export default defineComponent({
 			set(value) { this.$store.commit('device/set', { key: 'instanceTicker', value }); }
 		},
 
+		loadRawImages: {
+			get() { return this.$store.state.device.loadRawImages; },
+			set(value) { this.$store.commit('device/set', { key: 'loadRawImages', value }); }
+		},
+
+		disableShowingAnimatedImages: {
+			get() { return this.$store.state.device.disableShowingAnimatedImages; },
+			set(value) { this.$store.commit('device/set', { key: 'disableShowingAnimatedImages', value }); }
+		},
+
+		nsfw: {
+			get() { return this.$store.state.device.nsfw; },
+			set(value) { this.$store.commit('device/set', { key: 'nsfw', value }); }
+		},
+
 		enableInfiniteScroll: {
 			get() { return this.$store.state.device.enableInfiniteScroll; },
 			set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); }
 		},
-
-		deckAlwaysShowMainColumn: {
-			get() { return this.$store.state.device.deckAlwaysShowMainColumn; },
-			set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); }
-		},
-
-		deckColumnAlign: {
-			get() { return this.$store.state.device.deckColumnAlign; },
-			set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); }
-		},
 	},
 
 	watch: {
diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue
index 5451c8616b..a42a4614cc 100644
--- a/src/client/pages/settings/index.vue
+++ b/src/client/pages/settings/index.vue
@@ -1,35 +1,36 @@
 <template>
 <div class="vvcocwet" :class="{ wide: !narrow }" ref="el">
-	<div class="nav" v-if="!narrow || page == null">
-		<div class="menu">
-			<div class="label">{{ $t('basicSettings') }}</div>
-			<MkA class="item" :class="{ active: page === 'profile' }" replace to="/settings/profile"><Fa :icon="faUser" fixed-width class="icon"/>{{ $t('profile') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'privacy' }" replace to="/settings/privacy"><Fa :icon="faLockOpen" fixed-width class="icon"/>{{ $t('privacy') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'reaction' }" replace to="/settings/reaction"><Fa :icon="faLaugh" fixed-width class="icon"/>{{ $t('reaction') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'notifications' }" replace to="/settings/notifications"><Fa :icon="faBell" fixed-width class="icon"/>{{ $t('notifications') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'integration' }" replace to="/settings/integration"><Fa :icon="faShareAlt" fixed-width class="icon"/>{{ $t('integration') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'security' }" replace to="/settings/security"><Fa :icon="faLock" fixed-width class="icon"/>{{ $t('security') }}</MkA>
-		</div>
-		<div class="menu">
-			<div class="label">{{ $t('clientSettings') }}</div>
-			<MkA class="item" :class="{ active: page === 'general' }" replace to="/settings/general"><Fa :icon="faCogs" fixed-width class="icon"/>{{ $t('general') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'theme' }" replace to="/settings/theme"><Fa :icon="faPalette" fixed-width class="icon"/>{{ $t('theme') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'sidebar' }" replace to="/settings/sidebar"><Fa :icon="faListUl" fixed-width class="icon"/>{{ $t('sidebar') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'sounds' }" replace to="/settings/sounds"><Fa :icon="faMusic" fixed-width class="icon"/>{{ $t('sounds') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'plugins' }" replace to="/settings/plugins"><Fa :icon="faPlug" fixed-width class="icon"/>{{ $t('plugins') }}</MkA>
-		</div>
-		<div class="menu">
-			<div class="label">{{ $t('otherSettings') }}</div>
-			<MkA class="item" :class="{ active: page === 'import-export' }" replace to="/settings/import-export"><Fa :icon="faBoxes" fixed-width class="icon"/>{{ $t('importAndExport') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'mute-block' }" replace to="/settings/mute-block"><Fa :icon="faBan" fixed-width class="icon"/>{{ $t('muteAndBlock') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'word-mute' }" replace to="/settings/word-mute"><Fa :icon="faCommentSlash" fixed-width class="icon"/>{{ $t('wordMute') }}</MkA>
-			<MkA class="item" :class="{ active: page === 'api' }" replace to="/settings/api"><Fa :icon="faKey" fixed-width class="icon"/>API</MkA>
-			<MkA class="item" :class="{ active: page === 'other' }" replace to="/settings/other"><Fa :icon="faEllipsisH" fixed-width class="icon"/>{{ $t('other') }}</MkA>
-		</div>
-		<div class="menu">
-			<button class="_button item" @click="logout">{{ $t('logout') }}</button>
-		</div>
-	</div>
+	<FormBase class="nav" v-if="!narrow || page == null" :force-wide="!narrow">
+		<FormGroup>
+			<template #label>{{ $t('basicSettings') }}</template>
+			<FormLink :active="page === 'profile'" replace to="/settings/profile"><template #icon><Fa :icon="faUser"/></template>{{ $t('profile') }}</FormLink>
+			<FormLink :active="page === 'privacy'" replace to="/settings/privacy"><template #icon><Fa :icon="faLockOpen"/></template>{{ $t('privacy') }}</FormLink>
+			<FormLink :active="page === 'reaction'" replace to="/settings/reaction"><template #icon><Fa :icon="faLaugh"/></template>{{ $t('reaction') }}</FormLink>
+			<FormLink :active="page === 'notifications'" replace to="/settings/notifications"><template #icon><Fa :icon="faBell"/></template>{{ $t('notifications') }}</FormLink>
+			<FormLink :active="page === 'email'" replace to="/settings/email"><template #icon><Fa :icon="faEnvelope"/></template>{{ $t('email') }}</FormLink>
+			<FormLink :active="page === 'integration'" replace to="/settings/integration"><template #icon><Fa :icon="faShareAlt"/></template>{{ $t('integration') }}</FormLink>
+			<FormLink :active="page === 'security'" replace to="/settings/security"><template #icon><Fa :icon="faLock"/></template>{{ $t('security') }}</FormLink>
+		</FormGroup>
+		<FormGroup>
+			<template #label>{{ $t('clientSettings') }}</template>
+			<FormLink :active="page === 'general'" replace to="/settings/general"><template #icon><Fa :icon="faCogs"/></template>{{ $t('general') }}</FormLink>
+			<FormLink :active="page === 'theme'" replace to="/settings/theme"><template #icon><Fa :icon="faPalette"/></template>{{ $t('theme') }}</FormLink>
+			<FormLink :active="page === 'sidebar'" replace to="/settings/sidebar"><template #icon><Fa :icon="faListUl"/></template>{{ $t('sidebar') }}</FormLink>
+			<FormLink :active="page === 'sounds'" replace to="/settings/sounds"><template #icon><Fa :icon="faMusic"/></template>{{ $t('sounds') }}</FormLink>
+			<FormLink :active="page === 'plugins'" replace to="/settings/plugins"><template #icon><Fa :icon="faPlug"/></template>{{ $t('plugins') }}</FormLink>
+		</FormGroup>
+		<FormGroup>
+			<template #label>{{ $t('otherSettings') }}</template>
+			<FormLink :active="page === 'import-export'" replace to="/settings/import-export"><template #icon><Fa :icon="faBoxes"/></template>{{ $t('importAndExport') }}</FormLink>
+			<FormLink :active="page === 'mute-block'" replace to="/settings/mute-block"><template #icon><Fa :icon="faBan"/></template>{{ $t('muteAndBlock') }}</FormLink>
+			<FormLink :active="page === 'word-mute'" replace to="/settings/word-mute"><template #icon><Fa :icon="faCommentSlash"/></template>{{ $t('wordMute') }}</FormLink>
+			<FormLink :active="page === 'api'" replace to="/settings/api"><template #icon><Fa :icon="faKey"/></template>API</FormLink>
+			<FormLink :active="page === 'other'" replace to="/settings/other"><template #icon><Fa :icon="faEllipsisH"/></template>{{ $t('other') }}</FormLink>
+		</FormGroup>
+		<FormGroup>
+			<FormButton @click="logout" danger>{{ $t('logout') }}</FormButton>
+		</FormGroup>
+	</FormBase>
 	<div class="main">
 		<component :is="component" @info="onInfo"/>
 	</div>
@@ -37,13 +38,25 @@
 </template>
 
 <script lang="ts">
-import { computed, defineAsyncComponent, defineComponent, onMounted, ref } from 'vue';
+import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, ref, watch } from 'vue';
 import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes } from '@fortawesome/free-solid-svg-icons';
-import { faLaugh, faBell } from '@fortawesome/free-regular-svg-icons';
+import { faLaugh, faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
 import { store } from '@/store';
 import { i18n } from '@/i18n';
+import FormLink from '@/components/form/link.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormBase from '@/components/form/base.vue';
+import FormButton from '@/components/form/button.vue';
+import { scroll } from '../../scripts/scroll';
 
 export default defineComponent({
+	components: {
+		FormBase,
+		FormLink,
+		FormGroup,
+		FormButton,
+	},
+
 	props: {
 		page: {
 			type: String,
@@ -72,21 +85,35 @@ export default defineComponent({
 				case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue'));
 				case 'integration': return defineAsyncComponent(() => import('./integration.vue'));
 				case 'security': return defineAsyncComponent(() => import('./security.vue'));
+				case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));
 				case 'api': return defineAsyncComponent(() => import('./api.vue'));
+				case 'apps': return defineAsyncComponent(() => import('./apps.vue'));
 				case 'other': return defineAsyncComponent(() => import('./other.vue'));
 				case 'general': return defineAsyncComponent(() => import('./general.vue'));
+				case 'email': return defineAsyncComponent(() => import('./email.vue'));
+				case 'email/address': return defineAsyncComponent(() => import('./email-address.vue'));
 				case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
+				case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
+				case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
 				case 'sidebar': return defineAsyncComponent(() => import('./sidebar.vue'));
 				case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
+				case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
 				case 'plugins': return defineAsyncComponent(() => import('./plugins.vue'));
 				case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
+				case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
 				case 'regedit': return defineAsyncComponent(() => import('./regedit.vue'));
 				default: return null;
 			}
 		});
 
+		watch(component, () => {
+			nextTick(() => {
+				scroll(el.value, 0);
+			});
+		});
+
 		onMounted(() => {
-			narrow.value = el.value.offsetWidth < 650;
+			narrow.value = el.value.offsetWidth < 1025;
 		});
 
 		return {
@@ -100,7 +127,7 @@ export default defineComponent({
 				store.dispatch('logout');
 				location.href = '/';
 			},
-			faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes,
+			faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes, faEnvelope,
 		};
 	},
 });
@@ -108,63 +135,19 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .vvcocwet {
-	> .nav {
-		> .menu {
-			margin: 16px 0;
-
-			> .label {
-				padding: 8px 32px;
-				font-size: 80%;
-				opacity: 0.7;
-			}
-
-			> .item {
-				display: block;
-				width: 100%;
-				box-sizing: border-box;
-				padding: 0 32px;
-				line-height: 40px;
-				white-space: nowrap;
-				overflow: hidden;
-				text-overflow: ellipsis;
-				//background: var(--panel);
-				//border-bottom: solid 1px var(--divider);
-				transition: padding 0.2s ease, color 0.1s ease;
-
-				&:first-of-type {
-					//border-top: solid 1px var(--divider);
-				}
-
-				&.active {
-					color: var(--accent);
-					padding-left: 42px;
-				}
-
-				&:hover {
-					text-decoration: none;
-					padding-left: 42px;
-				}
-
-				> .icon {
-					margin-right: 0.5em;
-				}
-			}
-		}
-	}
-
 	&.wide {
 		display: flex;
+		max-width: 1100px;
+		margin: 0 auto;
 
 		> .nav {
-			width: 30%;
-			max-width: 300px;
-			font-size: 0.95em;
-			border-right: solid 1px var(--divider);
+			width: 32%;
+			box-sizing: border-box;
+			border-right: solid 0.5px var(--divider);
 		}
 
 		> .main {
 			flex: 1;
-			padding: 32px;
 			--baseContentWidth: 100%;
 
 			::v-deep(._section) {
diff --git a/src/client/pages/settings/notifications.vue b/src/client/pages/settings/notifications.vue
index ff0c276398..d26a11ef78 100644
--- a/src/client/pages/settings/notifications.vue
+++ b/src/client/pages/settings/notifications.vue
@@ -1,29 +1,31 @@
 <template>
-<div>
-	<div class="_section">
-		<MkButton full primary @click="configure"><Fa :icon="faCog"/> {{ $t('notificationSetting') }}</MkButton>
-	</div>
-	<div class="_section">
-		<MkButton full @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</MkButton>
-		<MkButton full @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</MkButton>
-		<MkButton full @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</MkButton>
-	</div>
-</div>
+<FormBase>
+	<FormLink @click="configure">{{ $t('notificationSetting') }}</FormLink>
+	<FormGroup>
+		<FormButton @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</FormButton>
+		<FormButton @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</FormButton>
+		<FormButton @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</FormButton>
+	</FormGroup>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faCog } from '@fortawesome/free-solid-svg-icons';
 import { faBell } from '@fortawesome/free-regular-svg-icons';
-import MkButton from '@/components/ui/button.vue';
-import MkSwitch from '@/components/ui/switch.vue';
+import FormButton from '@/components/form/button.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
 import { notificationTypes } from '../../../types';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkSwitch,
+		FormBase,
+		FormLink,
+		FormButton,
+		FormGroup,
 	},
 
 	emits: ['info'],
diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue
index 9c44d1b4f4..b3bab0e232 100644
--- a/src/client/pages/settings/other.vue
+++ b/src/client/pages/settings/other.vue
@@ -1,40 +1,43 @@
 <template>
-<div>
-	<div class="_section">
-		<div class="_card">
-			<div class="_content">
-				<MkSwitch v-model:value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote">
-					{{ $t('showFeaturedNotesInTimeline') }}
-				</MkSwitch>
-			</div>
-		</div>
-	</div>
-	<div class="_section">
-		<MkSwitch v-model:value="debug" @update:value="changeDebug">
+<FormBase>
+	<FormSwitch :value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote">
+		{{ $t('showFeaturedNotesInTimeline') }}
+	</FormSwitch>
+
+	<FormLink to="/settings/account-info">{{ $t('accountInfo') }}</FormLink>
+
+	<FormGroup>
+		<FormSwitch v-model:value="debug" @update:value="changeDebug">
 			DEBUG MODE
-		</MkSwitch>
-		<div v-if="debug">
-			<MkA to="/settings/regedit">RegEdit</MkA>
-			<MkButton @click="taskmanager">Task Manager</MkButton>
-		</div>
-	</div>
-</div>
+		</FormSwitch>
+		<template v-if="debug">
+			<FormLink to="/settings/regedit">RegEdit</FormLink>
+			<FormButton @click="taskmanager">Task Manager</FormButton>
+		</template>
+	</FormGroup>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineAsyncComponent, defineComponent } from 'vue';
 import { faEllipsisH } from '@fortawesome/free-solid-svg-icons';
-import MkSelect from '@/components/ui/select.vue';
-import MkSwitch from '@/components/ui/switch.vue';
-import MkButton from '@/components/ui/button.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/form/link.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
 import * as os from '@/os';
 import { debug } from '@/config';
 
 export default defineComponent({
 	components: {
-		MkSelect,
-		MkSwitch,
-		MkButton,
+		FormBase,
+		FormSelect,
+		FormSwitch,
+		FormButton,
+		FormLink,
+		FormGroup,
 	},
 
 	emits: ['info'],
diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue
index 27a949836a..09db077502 100644
--- a/src/client/pages/settings/privacy.vue
+++ b/src/client/pages/settings/privacy.vue
@@ -1,36 +1,43 @@
 <template>
-<div class="_section">
-	<div class="_card">
-		<div class="_content">
-			<MkSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</MkSwitch>
-			<MkSwitch v-model:value="autoAcceptFollowed" v-if="isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</MkSwitch>
-		</div>
-		<div class="_content">
-			<MkSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</MkSwitch>
-			<MkSelect v-model:value="defaultNoteVisibility" style="margin-bottom: 8px;" v-if="!rememberNoteVisibility">
-				<template #label>{{ $t('defaultNoteVisibility') }}</template>
-				<option value="public">{{ $t('_visibility.public') }}</option>
-				<option value="home">{{ $t('_visibility.home') }}</option>
-				<option value="followers">{{ $t('_visibility.followers') }}</option>
-				<option value="specified">{{ $t('_visibility.specified') }}</option>
-			</MkSelect>
-			<MkSwitch v-model:value="defaultNoteLocalOnly" v-if="!rememberNoteVisibility">{{ $t('_visibility.localOnly') }}</MkSwitch>
-		</div>
-	</div>
-</div>
+<FormBase>
+	<FormGroup>
+		<FormSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</FormSwitch>
+		<FormSwitch v-model:value="autoAcceptFollowed" :disabled="!isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</FormSwitch>
+		<template #caption>{{ $t('lockedAccountInfo') }}</template>
+	</FormGroup>
+	<FormSwitch v-model:value="noCrawle" @update:value="save()">
+		{{ $t('noCrawle') }}
+		<template #desc>{{ $t('noCrawleDescription') }}</template>
+	</FormSwitch>
+	<FormSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</FormSwitch>
+	<FormGroup v-if="!rememberNoteVisibility">
+		<template #label>{{ $t('defaultNoteVisibility') }}</template>
+		<FormSelect v-model:value="defaultNoteVisibility">
+			<option value="public">{{ $t('_visibility.public') }}</option>
+			<option value="home">{{ $t('_visibility.home') }}</option>
+			<option value="followers">{{ $t('_visibility.followers') }}</option>
+			<option value="specified">{{ $t('_visibility.specified') }}</option>
+		</FormSelect>
+		<FormSwitch v-model:value="defaultNoteLocalOnly">{{ $t('_visibility.localOnly') }}</FormSwitch>
+	</FormGroup>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faLockOpen } from '@fortawesome/free-solid-svg-icons';
-import MkSelect from '@/components/ui/select.vue';
-import MkSwitch from '@/components/ui/switch.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkSelect,
-		MkSwitch,
+		FormBase,
+		FormSelect,
+		FormGroup,
+		FormSwitch,
 	},
 
 	emits: ['info'],
@@ -43,6 +50,7 @@ export default defineComponent({
 			},
 			isLocked: false,
 			autoAcceptFollowed: false,
+			noCrawle: false,
 		}
 	},
 
@@ -66,6 +74,7 @@ export default defineComponent({
 	created() {
 		this.isLocked = this.$store.state.i.isLocked;
 		this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed;
+		this.noCrawle = this.$store.state.i.noCrawle;
 	},
 
 	mounted() {
@@ -77,6 +86,7 @@ export default defineComponent({
 			os.api('i/update', {
 				isLocked: !!this.isLocked,
 				autoAcceptFollowed: !!this.autoAcceptFollowed,
+				noCrawle: !!this.noCrawle,
 			});
 		}
 	}
diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue
index 6a523e08cf..4fc4783c49 100644
--- a/src/client/pages/settings/profile.vue
+++ b/src/client/pages/settings/profile.vue
@@ -1,79 +1,67 @@
 <template>
-<div class="_section">
-	<div class="llvierxe _card">
-		<div class="_title"><Fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div>
-		<div class="_content">
-			<div class="header" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner">
-				<MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/>
-			</div>
-		
-			<MkInput v-model:value="name" :max="30">
-				<span>{{ $t('_profile.name') }}</span>
-			</MkInput>
-
-			<MkTextarea v-model:value="description" :max="500">
-				<span>{{ $t('_profile.description') }}</span>
-				<template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
-			</MkTextarea>
-
-			<MkInput v-model:value="location">
-				<span>{{ $t('location') }}</span>
-				<template #prefix><Fa :icon="faMapMarkerAlt"/></template>
-			</MkInput>
-
-			<MkInput v-model:value="birthday" type="date">
-				<template #title>{{ $t('birthday') }}</template>
-				<template #prefix><Fa :icon="faBirthdayCake"/></template>
-			</MkInput>
-
-			<details class="fields">
-				<summary>{{ $t('_profile.metadata') }}</summary>
-				<div class="row">
-					<MkInput v-model:value="fieldName0">{{ $t('_profile.metadataLabel') }}</MkInput>
-					<MkInput v-model:value="fieldValue0">{{ $t('_profile.metadataContent') }}</MkInput>
-				</div>
-				<div class="row">
-					<MkInput v-model:value="fieldName1">{{ $t('_profile.metadataLabel') }}</MkInput>
-					<MkInput v-model:value="fieldValue1">{{ $t('_profile.metadataContent') }}</MkInput>
-				</div>
-				<div class="row">
-					<MkInput v-model:value="fieldName2">{{ $t('_profile.metadataLabel') }}</MkInput>
-					<MkInput v-model:value="fieldValue2">{{ $t('_profile.metadataContent') }}</MkInput>
-				</div>
-				<div class="row">
-					<MkInput v-model:value="fieldName3">{{ $t('_profile.metadataLabel') }}</MkInput>
-					<MkInput v-model:value="fieldValue3">{{ $t('_profile.metadataContent') }}</MkInput>
-				</div>
-			</details>
-
-			<MkSwitch v-model:value="isBot">{{ $t('flagAsBot') }}</MkSwitch>
-			<MkSwitch v-model:value="isCat">{{ $t('flagAsCat') }}</MkSwitch>
-		</div>
-		<div class="_footer">
-			<MkButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
-		</div>
+<FormBase class="llvierxe">
+	<div class="header _formItem" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner">
+		<MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/>
 	</div>
-</div>
+
+	<FormInput v-model:value="name" :max="30">
+		<span>{{ $t('_profile.name') }}</span>
+	</FormInput>
+
+	<FormTextarea v-model:value="description" :max="500">
+		<span>{{ $t('_profile.description') }}</span>
+		<template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
+	</FormTextarea>
+
+	<FormInput v-model:value="location">
+		<span>{{ $t('location') }}</span>
+		<template #prefix><Fa :icon="faMapMarkerAlt"/></template>
+	</FormInput>
+
+	<FormInput v-model:value="birthday" type="date">
+		<span>{{ $t('birthday') }}</span>
+		<template #prefix><Fa :icon="faBirthdayCake"/></template>
+	</FormInput>
+
+	<FormGroup>
+		<FormButton @click="editMetadata" primary>{{ $t('_profile.metadataEdit') }}</FormButton>
+		<template #caption>{{ $t('_profile.metadataDescription') }}</template>
+	</FormGroup>
+
+	<FormSwitch v-model:value="isCat">{{ $t('flagAsCat') }}<template #desc>{{ $t('flagAsCatDescription') }}</template></FormSwitch>
+
+	<FormSwitch v-model:value="isBot">{{ $t('flagAsBot') }}<template #desc>{{ $t('flagAsBotDescription') }}</template></FormSwitch>
+
+	<FormSwitch v-model:value="alwaysMarkNsfw">{{ $t('alwaysMarkSensitive') }}</FormSwitch>
+
+	<FormButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</FormButton>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons';
 import { faSave } from '@fortawesome/free-regular-svg-icons';
-import MkButton from '@/components/ui/button.vue';
-import MkInput from '@/components/ui/input.vue';
-import MkTextarea from '@/components/ui/textarea.vue';
-import MkSwitch from '@/components/ui/switch.vue';
+import FormButton from '@/components/form/button.vue';
+import FormInput from '@/components/form/input.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormTuple from '@/components/form/tuple.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
 import { host } from '@/config';
 import { selectFile } from '@/scripts/select-file';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkInput,
-		MkTextarea,
-		MkSwitch,
+		FormButton,
+		FormInput,
+		FormTextarea,
+		FormSwitch,
+		FormTuple,
+		FormBase,
+		FormGroup,
 	},
 	
 	emits: ['info'],
@@ -101,6 +89,7 @@ export default defineComponent({
 			bannerId: null,
 			isBot: false,
 			isCat: false,
+			alwaysMarkNsfw: false,
 			saving: false,
 			faSave, faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake
 		}
@@ -115,6 +104,7 @@ export default defineComponent({
 		this.bannerId = this.$store.state.i.bannerId;
 		this.isBot = this.$store.state.i.isBot;
 		this.isCat = this.$store.state.i.isCat;
+		this.alwaysMarkNsfw = this.$store.state.i.alwaysMarkNsfw;
 
 		this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null;
 		this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null;
@@ -147,7 +137,60 @@ export default defineComponent({
 			});
 		},
 
-		save(notify) {
+		async editMetadata() {
+			const { canceled, result } = await os.form(this.$t('_profile.metadata'), {
+				fieldName0: {
+					type: 'string',
+					label: this.$t('_profile.metadataLabel') + ' 1',
+					default: this.fieldName0,
+				},
+				fieldValue0: {
+					type: 'string',
+					label: this.$t('_profile.metadataContent') + ' 1',
+					default: this.fieldValue0,
+				},
+				fieldName1: {
+					type: 'string',
+					label: this.$t('_profile.metadataLabel') + ' 2',
+					default: this.fieldName1,
+				},
+				fieldValue1: {
+					type: 'string',
+					label: this.$t('_profile.metadataContent') + ' 2',
+					default: this.fieldValue1,
+				},
+				fieldName2: {
+					type: 'string',
+					label: this.$t('_profile.metadataLabel') + ' 3',
+					default: this.fieldName2,
+				},
+				fieldValue2: {
+					type: 'string',
+					label: this.$t('_profile.metadataContent') + ' 3',
+					default: this.fieldValue2,
+				},
+				fieldName3: {
+					type: 'string',
+					label: this.$t('_profile.metadataLabel') + ' 4',
+					default: this.fieldName3,
+				},
+				fieldValue3: {
+					type: 'string',
+					label: this.$t('_profile.metadataContent') + ' 4',
+					default: this.fieldValue3,
+				},
+			});
+			if (canceled) return;
+
+			this.fieldName0 = result.fieldName0;
+			this.fieldValue0 = result.fieldValue0;
+			this.fieldName1 = result.fieldName1;
+			this.fieldValue1 = result.fieldValue1;
+			this.fieldName2 = result.fieldName2;
+			this.fieldValue2 = result.fieldValue2;
+			this.fieldName3 = result.fieldName3;
+			this.fieldValue3 = result.fieldValue3;
+
 			const fields = [
 				{ name: this.fieldName0, value: this.fieldValue0 },
 				{ name: this.fieldName1, value: this.fieldValue1 },
@@ -155,6 +198,19 @@ export default defineComponent({
 				{ name: this.fieldName3, value: this.fieldValue3 },
 			];
 
+			os.api('i/update', {
+				fields,
+			}).then(i => {
+				os.success();
+			}).catch(err => {
+				os.dialog({
+					type: 'error',
+					text: err.id
+				});
+			});
+		},
+
+		save(notify) {
 			this.saving = true;
 
 			os.api('i/update', {
@@ -162,9 +218,9 @@ export default defineComponent({
 				description: this.description || null,
 				location: this.location || null,
 				birthday: this.birthday || null,
-				fields,
 				isBot: !!this.isBot,
 				isCat: !!this.isCat,
+				alwaysMarkNsfw: !!this.alwaysMarkNsfw,
 			}).then(i => {
 				this.saving = false;
 				this.$store.state.i.avatarId = i.avatarId;
@@ -189,41 +245,29 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .llvierxe {
-	> ._content {
-		> .header {
-			position: relative;
-			height: 150px;
-			overflow: hidden;
-			background-size: cover;
-			background-position: center;
-			border-radius: 5px;
-			border: solid 1px var(--divider);
-			box-sizing: border-box;
+	> .header {
+		position: relative;
+		height: 150px;
+		overflow: hidden;
+		background-size: cover;
+		background-position: center;
+		border-radius: 5px;
+		border: solid 1px var(--divider);
+		box-sizing: border-box;
+		cursor: pointer;
+
+		> .avatar {
+			position: absolute;
+			top: 0;
+			bottom: 0;
+			left: 0;
+			right: 0;
+			display: block;
+			width: 72px;
+			height: 72px;
+			margin: auto;
 			cursor: pointer;
-
-			> .avatar {
-				position: absolute;
-				top: 0;
-				bottom: 0;
-				left: 0;
-				right: 0;
-				display: block;
-				width: 72px;
-				height: 72px;
-				margin: auto;
-				cursor: pointer;
-				box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5);
-			}
-		}
-
-		> .fields {
-			> .row {
-				> * {
-					display: inline-block;
-					width: 50%;
-					margin-bottom: 0;
-				}
-			}
+			box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5);
 		}
 	}
 }
diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue
index 88de091441..75dae29068 100644
--- a/src/client/pages/settings/reaction.vue
+++ b/src/client/pages/settings/reaction.vue
@@ -1,9 +1,8 @@
 <template>
-<div class="_section">
-	<div class="_card">
-		<div class="_title"><Fa :icon="faLaugh"/> {{ $t('reaction') }}</div>
-		<div class="_content">
-			<div class="_caption" style="padding: 0 8px 8px 8px;">{{ $t('reactionSettingDescription') }}</div>
+<FormBase>
+	<div class="_formItem">
+		<div class="_formLabel">{{ $t('reactionSettingDescription') }}</div>
+		<div class="_formPanel">
 			<XDraggable class="zoaiodol" :list="reactions" animation="150" delay="100" delay-on-touch-only="true">
 				<button class="_button item" v-for="reaction in reactions" :key="reaction" @click="remove(reaction, $event)">
 					<MkEmoji :emoji="reaction" :normal="true"/>
@@ -12,26 +11,25 @@
 					<button>a</button>
 				</template>
 			</XDraggable>
-			<div class="_caption" style="padding: 8px;">{{ $t('reactionSettingDescription2') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></div>
-			<MkRadios v-model="reactionPickerWidth">
-				<template #desc>{{ $t('width') }}</template>
-				<option :value="1">{{ $t('small') }}</option>
-				<option :value="2">{{ $t('medium') }}</option>
-				<option :value="3">{{ $t('large') }}</option>
-			</MkRadios>
-			<MkRadios v-model="reactionPickerHeight">
-				<template #desc>{{ $t('height') }}</template>
-				<option :value="1">{{ $t('small') }}</option>
-				<option :value="2">{{ $t('medium') }}</option>
-				<option :value="3">{{ $t('large') }}</option>
-			</MkRadios>
-		</div>
-		<div class="_footer">
-			<MkButton inline @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton>
-			<MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton>
 		</div>
+		<div class="_formCaption">{{ $t('reactionSettingDescription2') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></div>
 	</div>
-</div>
+
+	<FormRadios v-model="reactionPickerWidth">
+		<template #desc>{{ $t('width') }}</template>
+		<option :value="1">{{ $t('small') }}</option>
+		<option :value="2">{{ $t('medium') }}</option>
+		<option :value="3">{{ $t('large') }}</option>
+	</FormRadios>
+	<FormRadios v-model="reactionPickerHeight">
+		<template #desc>{{ $t('height') }}</template>
+		<option :value="1">{{ $t('small') }}</option>
+		<option :value="2">{{ $t('medium') }}</option>
+		<option :value="3">{{ $t('large') }}</option>
+	</FormRadios>
+	<FormButton @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</FormButton>
+	<FormButton danger @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</FormButton>
+</FormBase>
 </template>
 
 <script lang="ts">
@@ -39,20 +37,19 @@ import { defineComponent } from 'vue';
 import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
 import { faUndo } from '@fortawesome/free-solid-svg-icons';
 import { VueDraggableNext } from 'vue-draggable-next';
-import MkInput from '@/components/ui/input.vue';
-import MkButton from '@/components/ui/button.vue';
-import MkSwitch from '@/components/ui/switch.vue';
-import MkRadios from '@/components/ui/radios.vue';
-import { emojiRegexWithCustom } from '../../../misc/emoji-regex';
+import FormInput from '@/components/form/input.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/form/base.vue';
+import FormButton from '@/components/form/button.vue';
 import { defaultSettings } from '@/store';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkInput,
-		MkButton,
-		MkSwitch,
-		MkRadios,
+		FormInput,
+		FormButton,
+		FormBase,
+		FormRadios,
 		XDraggable: VueDraggableNext,
 	},
 
@@ -62,7 +59,11 @@ export default defineComponent({
 		return {
 			INFO: {
 				title: this.$t('reaction'),
-				icon: faLaugh
+				icon: faLaugh,
+				action: {
+					icon: faEye,
+					handler: this.preview
+				}
 			},
 			reactions: JSON.parse(JSON.stringify(this.$store.state.settings.reactions)),
 			faLaugh, faSave, faEye, faUndo
@@ -144,8 +145,6 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .zoaiodol {
-	border: solid 1px var(--divider);
-	border-radius: var(--radius);
 	padding: 16px;
 
 	> .item {
diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue
index 98863679c4..8d84d08f78 100644
--- a/src/client/pages/settings/security.vue
+++ b/src/client/pages/settings/security.vue
@@ -1,29 +1,45 @@
 <template>
-<div>
-	<div class="_section">
-		<X2fa/>
-	</div>
-	<div class="_section">
-		<MkButton primary @click="change()" full>{{ $t('changePassword') }}</MkButton>
-	</div>
-	<div class="_section">
-		<MkButton class="_vMargin" primary @click="regenerateToken" full><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</MkButton>
-		<div class="_caption _vMargin" style="padding: 0 6px;">{{ $t('regenerateLoginTokenDescription') }}</div>
-	</div>
-</div>
+<FormBase>
+	<X2fa/>
+	<FormLink to="/settings/2fa"><template #icon><Fa :icon="faMobileAlt"/></template>{{ $t('twoStepAuthentication') }}</FormLink>
+	<FormButton primary @click="change()">{{ $t('changePassword') }}</FormButton>
+	<FormPagination :pagination="pagination">
+		<template #label>{{ $t('signinHistory') }}</template>
+		<template #default="{items}">
+			<div class="_formPanel timnmucd" v-for="item in items" :key="item.id">
+				<header>
+					<Fa class="icon succ" :icon="faCheck" v-if="item.success"/>
+					<Fa class="icon fail" :icon="faTimesCircle" v-else/>
+					<code class="ip _monospace">{{ item.ip }}</code>
+					<MkTime :time="item.createdAt" class="time"/>
+				</header>
+			</div>
+		</template>
+	</FormPagination>
+	<FormGroup>
+		<FormButton danger @click="regenerateToken"><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</FormButton>
+		<template #caption>{{ $t('regenerateLoginTokenDescription') }}</template>
+	</FormGroup>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
-import { faLock, faSyncAlt } from '@fortawesome/free-solid-svg-icons';
-import MkButton from '@/components/ui/button.vue';
-import X2fa from './security.2fa.vue';
+import { faCheck, faTimesCircle, faLock, faSyncAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons';
+import FormBase from '@/components/form/base.vue';
+import FormLink from '@/components/form/link.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
+import FormPagination from '@/components/form/pagination.vue';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		X2fa,
+		FormBase,
+		FormLink,
+		FormButton,
+		FormPagination,
+		FormGroup,
 	},
 	
 	emits: ['info'],
@@ -34,7 +50,11 @@ export default defineComponent({
 				title: this.$t('security'),
 				icon: faLock
 			},
-			faLock, faSyncAlt
+			pagination: {
+				endpoint: 'i/signin-history',
+				limit: 5,
+			},
+			faLock, faSyncAlt, faCheck, faTimesCircle, faMobileAlt,
 		}
 	},
 
@@ -98,3 +118,32 @@ export default defineComponent({
 	}
 });
 </script>
+
+<style lang="scss" scoped>
+.timnmucd {
+	padding: 16px;
+
+	> header {
+		display: flex;
+		align-items: center;
+
+		> .icon {
+			width: 1em;
+			margin-right: 0.75em;
+
+			&.succ {
+				color: var(--success);
+			}
+
+			&.fail {
+				color: var(--error);
+			}
+		}
+
+		> .time {
+			margin-left: auto;
+			opacity: 0.7;
+		}
+	}
+}
+</style>
diff --git a/src/client/pages/settings/sidebar.vue b/src/client/pages/settings/sidebar.vue
index 2ab5acf936..4138aaf733 100644
--- a/src/client/pages/settings/sidebar.vue
+++ b/src/client/pages/settings/sidebar.vue
@@ -1,41 +1,41 @@
 <template>
-<div class="_section">
-	<div class="_card">
-		<div class="_content">
-			<MkTextarea v-model:value="items" tall>
-				<span>{{ $t('sidebar') }}</span>
-				<template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template>
-			</MkTextarea>
-		</div>
-		<div class="_content">
-			<div>{{ $t('display') }}</div>
-			<MkRadio v-model="sidebarDisplay" value="full">{{ $t('_sidebar.full') }}</MkRadio>
-			<MkRadio v-model="sidebarDisplay" value="icon">{{ $t('_sidebar.icon') }}</MkRadio>
-			<!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
-		</div>
-		<div class="_footer">
-			<MkButton inline @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
-			<MkButton inline @click="reset()"><Fa :icon="faRedo"/> {{ $t('default') }}</MkButton>
-		</div>
-	</div>
-</div>
+<FormBase>
+	<FormTextarea v-model:value="items" tall>
+		<span>{{ $t('sidebar') }}</span>
+		<template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template>
+	</FormTextarea>
+
+	<FormRadios v-model="sidebarDisplay">
+		<template #desc>{{ $t('display') }}</template>
+		<option value="full">{{ $t('_sidebar.full') }}</option>
+		<option value="icon">{{ $t('_sidebar.icon') }}</option>
+		<!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
+	</FormRadios>
+
+	<FormButton @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</FormButton>
+	<FormButton @click="reset()" danger><Fa :icon="faRedo"/> {{ $t('default') }}</FormButton>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons';
-import MkButton from '@/components/ui/button.vue';
-import MkTextarea from '@/components/ui/textarea.vue';
-import MkRadio from '@/components/ui/radio.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormButton from '@/components/form/button.vue';
 import { defaultDeviceUserSettings } from '@/store';
 import * as os from '@/os';
 import { sidebarDef } from '@/sidebar';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkTextarea,
-		MkRadio,
+		FormBase,
+		FormButton,
+		FormTextarea,
+		FormRadios,
 	},
 
 	emits: ['info'],
@@ -102,7 +102,3 @@ export default defineComponent({
 	},
 });
 </script>
-
-<style lang="scss" scoped>
-
-</style>
diff --git a/src/client/pages/settings/sounds.vue b/src/client/pages/settings/sounds.vue
index fc6b751fed..f19be54e82 100644
--- a/src/client/pages/settings/sounds.vue
+++ b/src/client/pages/settings/sounds.vue
@@ -1,62 +1,35 @@
 <template>
-<div class="_section">
-	<div class="_card">
-		<div class="_title"><Fa :icon="faMusic"/> {{ $t('sounds') }}</div>
-		<div class="_content">
-			<MkRange v-model:value="sfxVolume" :min="0" :max="1" :step="0.1">
-				<Fa slot="icon" :icon="volumeIcon"/>
-				<span slot="title">{{ $t('volume') }}</span>
-			</MkRange>
-		</div>
-		<div class="_content">
-			<MkSelect v-model:value="sfxNote">
-				<template #label>{{ $t('_sfx.note') }}</template>
-				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
-				<template #text><button class="_textButton" @click="listen(sfxNote)" v-if="sfxNote"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
-			</MkSelect>
-			<MkSelect v-model:value="sfxNoteMy">
-				<template #label>{{ $t('_sfx.noteMy') }}</template>
-				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
-				<template #text><button class="_textButton" @click="listen(sfxNoteMy)" v-if="sfxNoteMy"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
-			</MkSelect>
-			<MkSelect v-model:value="sfxNotification">
-				<template #label>{{ $t('_sfx.notification') }}</template>
-				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
-				<template #text><button class="_textButton" @click="listen(sfxNotification)" v-if="sfxNotification"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
-			</MkSelect>
-			<MkSelect v-model:value="sfxChat">
-				<template #label>{{ $t('_sfx.chat') }}</template>
-				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
-				<template #text><button class="_textButton" @click="listen(sfxChat)" v-if="sfxChat"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
-			</MkSelect>
-			<MkSelect v-model:value="sfxChatBg">
-				<template #label>{{ $t('_sfx.chatBg') }}</template>
-				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
-				<template #text><button class="_textButton" @click="listen(sfxChatBg)" v-if="sfxChatBg"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
-			</MkSelect>
-			<MkSelect v-model:value="sfxAntenna">
-				<template #label>{{ $t('_sfx.antenna') }}</template>
-				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
-				<template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
-			</MkSelect>
-			<MkSelect v-model:value="sfxChannel">
-				<template #label>{{ $t('_sfx.channel') }}</template>
-				<option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option>
-				<template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template>
-			</MkSelect>
-		</div>
-	</div>
-</div>
+<FormBase>
+	<FormRange v-model:value="masterVolume" :min="0" :max="1" :step="0.05">
+		<template #label><Fa :icon="volumeIcon" :key="volumeIcon"/> {{ $t('masterVolume') }}</template>
+	</FormRange>
+
+	<FormGroup>
+		<template #label>{{ $t('sounds') }}</template>
+		<FormButton v-for="type in Object.keys(sounds)" :key="type" :center="false" @click="edit(type)">
+			{{ $t('_sfx.' + type) }}
+			<template #suffix>{{ sounds[type].type || $t('none') }}</template>
+			<template #suffixIcon><Fa :icon="faChevronDown"/></template>
+		</FormButton>
+	</FormGroup>
+
+	<FormButton @click="reset()" danger><Fa :icon="faRedo"/> {{ $t('default') }}</FormButton>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
-import { faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons';
-import MkSelect from '@/components/ui/select.vue';
-import MkRange from '@/components/ui/range.vue';
+import { faMusic, faPlay, faVolumeUp, faVolumeMute, faChevronDown, faRedo } from '@fortawesome/free-solid-svg-icons';
+import FormRange from '@/components/form/range.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormBase from '@/components/form/base.vue';
+import FormButton from '@/components/form/button.vue';
+import FormGroup from '@/components/form/group.vue';
 import * as os from '@/os';
+import { device, defaultDeviceSettings } from '@/cold-storage';
+import { playFile } from '@/scripts/sound';
 
-const sounds = [
+const soundsTypes = [
 	null,
 	'syuilo/up',
 	'syuilo/down',
@@ -73,6 +46,8 @@ const sounds = [
 	'syuilo/square-pico',
 	'syuilo/reverved',
 	'syuilo/ryukyu',
+	'syuilo/kick',
+	'syuilo/snare',
 	'aisha/1',
 	'aisha/2',
 	'aisha/3',
@@ -82,71 +57,98 @@ const sounds = [
 
 export default defineComponent({
 	components: {
-		MkSelect,
-		MkRange,
+		FormSelect,
+		FormButton,
+		FormBase,
+		FormRange,
+		FormGroup,
 	},
 
+	emits: ['info'],
+
 	data() {
 		return {
-			sounds,
-			faMusic, faPlay, faVolumeUp, faVolumeMute,
+			INFO: {
+				title: this.$t('sounds'),
+				icon: faMusic
+			},
+			sounds: {},
+			faMusic, faPlay, faVolumeUp, faVolumeMute, faChevronDown, faRedo,
 		}
 	},
 
 	computed: {
-		sfxVolume: {
-			get() { return this.$store.state.device.sfxVolume; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); }
+		masterVolume: { // TODO: (外部)関数にcomputedを使うのはアレなので直す
+			get() { return device.get('sound_masterVolume'); },
+			set(value) { device.set('sound_masterVolume', value); }
 		},
-
-		sfxNote: {
-			get() { return this.$store.state.device.sfxNote; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxNote', value }); }
-		},
-
-		sfxNoteMy: {
-			get() { return this.$store.state.device.sfxNoteMy; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxNoteMy', value }); }
-		},
-
-		sfxNotification: {
-			get() { return this.$store.state.device.sfxNotification; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxNotification', value }); }
-		},
-
-		sfxChat: {
-			get() { return this.$store.state.device.sfxChat; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxChat', value }); }
-		},
-
-		sfxChatBg: {
-			get() { return this.$store.state.device.sfxChatBg; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxChatBg', value }); }
-		},
-
-		sfxAntenna: {
-			get() { return this.$store.state.device.sfxAntenna; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); }
-		},
-
-		sfxChannel: {
-			get() { return this.$store.state.device.sfxChannel; },
-			set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); }
-		},
-
-		volumeIcon: {
-			get() {
-				return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp;
-			}
+		volumeIcon() {
+			return this.masterVolume === 0 ? faVolumeMute : faVolumeUp;
 		}
 	},
 
+	created() {
+		this.sounds.note = device.get('sound_note');
+		this.sounds.noteMy = device.get('sound_noteMy');
+		this.sounds.notification = device.get('sound_notification');
+		this.sounds.chat = device.get('sound_chat');
+		this.sounds.chatBg = device.get('sound_chatBg');
+		this.sounds.antenna = device.get('sound_antenna');
+		this.sounds.channel = device.get('sound_channel');
+		this.sounds.reversiPutBlack = device.get('sound_reversiPutBlack');
+		this.sounds.reversiPutWhite = device.get('sound_reversiPutWhite');
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
 	methods: {
-		listen(sound) {
-			const audio = new Audio(`/assets/sounds/${sound}.mp3`);
-			audio.volume = this.$store.state.device.sfxVolume;
-			audio.play();
+		async edit(type) {
+			const { canceled, result } = await os.form(this.$t('_sfx.' + type), {
+				type: {
+					type: 'enum',
+					enum: soundsTypes.map(x => ({
+						value: x,
+						label: x == null ? this.$t('none') : x,
+					})),
+					label: this.$t('sound'),
+					default: this.sounds[type].type,
+				},
+				volume: {
+					type: 'range',
+					mim: 0,
+					max: 1,
+					step: 0.05,
+					label: this.$t('volume'),
+					default: this.sounds[type].volume
+				},
+				listen: {
+					type: 'button',
+					content: this.$t('listen'),
+					action: (_, values) => {
+						playFile(values.type, values.volume);
+					}
+				}
+			});
+			if (canceled) return;
+
+			const v = {
+				type: result.type,
+				volume: result.volume,
+			};
+
+			device.set('sound_' + type, v);
+			this.sounds[type] = v;
 		},
+
+		reset() {
+			for (const sound of Object.keys(this.sounds)) {
+				const v = defaultDeviceSettings['sound_' + sound];
+				device.set('sound_' + sound, v);
+				this.sounds[sound] = v;
+			}
+		}
 	}
 });
 </script>
diff --git a/src/client/pages/settings/theme.install.vue b/src/client/pages/settings/theme.install.vue
new file mode 100644
index 0000000000..c3f2565cca
--- /dev/null
+++ b/src/client/pages/settings/theme.install.vue
@@ -0,0 +1,106 @@
+<template>
+<FormBase>
+	<FormGroup>
+		<FormTextarea v-model:value="installThemeCode">
+			<span>{{ $t('_theme.code') }}</span>
+		</FormTextarea>
+		<FormButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</FormButton>
+	</FormGroup>
+
+	<FormButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormLink from '@/components/form/link.vue';
+import FormButton from '@/components/form/button.vue';
+import { applyTheme, validateTheme } from '@/scripts/theme';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		FormTextarea,
+		FormSelect,
+		FormRadios,
+		FormBase,
+		FormGroup,
+		FormLink,
+		FormButton,
+	},
+
+	emits: ['info'],
+
+	data() {
+		return {
+			INFO: {
+				title: this.$t('_theme.install'),
+				icon: faDownload
+			},
+			installThemeCode: null,
+			faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye
+		}
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
+	methods: {
+		parseThemeCode(code) {
+			let theme;
+
+			try {
+				theme = JSON5.parse(code);
+			} catch (e) {
+				os.dialog({
+					type: 'error',
+					text: this.$t('_theme.invalid')
+				});
+				return false;
+			}
+			if (!validateTheme(theme)) {
+				os.dialog({
+					type: 'error',
+					text: this.$t('_theme.invalid')
+				});
+				return false;
+			}
+			if (this.$store.state.device.themes.some(t => t.id === theme.id)) {
+				os.dialog({
+					type: 'info',
+					text: this.$t('_theme.alreadyInstalled')
+				});
+				return false;
+			}
+
+			return theme;
+		},
+
+		preview(code) {
+			const theme = this.parseThemeCode(code);
+			if (theme) applyTheme(theme, false);
+		},
+
+		install(code) {
+			const theme = this.parseThemeCode(code);
+			if (!theme) return;
+			const themes = this.$store.state.device.themes.concat(theme);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+			os.dialog({
+				type: 'success',
+				text: this.$t('_theme.installed', { name: theme.name })
+			});
+		},
+	}
+});
+</script>
diff --git a/src/client/pages/settings/theme.manage.vue b/src/client/pages/settings/theme.manage.vue
new file mode 100644
index 0000000000..a7bd97a4d5
--- /dev/null
+++ b/src/client/pages/settings/theme.manage.vue
@@ -0,0 +1,103 @@
+<template>
+<FormBase>
+	<FormSelect v-model:value="selectedThemeId">
+		<template #label>{{ $t('installedThemes') }}</template>
+		<option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+		<optgroup :label="$t('builtinThemes')">
+			<option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+		</optgroup>
+	</FormSelect>
+	<template v-if="selectedTheme">
+		<FormInput readonly :value="selectedTheme.author">
+			<span>{{ $t('author') }}</span>
+		</FormInput>
+		<FormTextarea readonly tall :value="selectedThemeCode">
+			<span>{{ $t('_theme.code') }}</span>
+			<template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template>
+		</FormTextarea>
+		<FormButton @click="uninstall()" danger v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</FormButton>
+	</template>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/form/button.vue';
+import { Theme, builtinThemes } from '@/scripts/theme';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import * as os from '@/os';
+
+export default defineComponent({
+	components: {
+		FormTextarea,
+		FormSelect,
+		FormRadios,
+		FormBase,
+		FormGroup,
+		FormInput,
+		FormButton,
+	},
+
+	emits: ['info'],
+	
+	data() {
+		return {
+			INFO: {
+				title: this.$t('_theme.manage'),
+				icon: faFolderOpen
+			},
+			builtinThemes,
+			selectedThemeId: null,
+			faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye
+		}
+	},
+
+	computed: {
+		themes(): Theme[] {
+			return builtinThemes.concat(this.$store.state.device.themes);
+		},
+
+		installedThemes(): Theme[] {
+			return this.$store.state.device.themes;
+		},
+	
+		selectedTheme() {
+			if (this.selectedThemeId == null) return null;
+			return this.themes.find(x => x.id === this.selectedThemeId);
+		},
+
+		selectedThemeCode() {
+			if (this.selectedTheme == null) return null;
+			return JSON5.stringify(this.selectedTheme, null, '\t');
+		},
+	},
+
+	mounted() {
+		this.$emit('info', this.INFO);
+	},
+
+	methods: {
+		copyThemeCode() {
+			copyToClipboard(this.selectedThemeCode);
+			os.success();
+		},
+
+		uninstall() {
+			const theme = this.selectedTheme;
+			const themes = this.$store.state.device.themes.filter(t => t.id != theme.id);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+			os.success();
+		},
+	}
+});
+</script>
diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue
index c023d56dea..50dcf4952c 100644
--- a/src/client/pages/settings/theme.vue
+++ b/src/client/pages/settings/theme.vue
@@ -1,7 +1,26 @@
 <template>
-<div class="">
-	<div class="rfqxtzch _card _vMargin">
-		<div class="_content">
+<FormBase>
+	<FormSelect v-model:value="lightTheme" v-if="!darkMode">
+		<template #label>{{ $t('themeForLightMode') }}</template>
+		<optgroup :label="$t('lightThemes')">
+			<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+		</optgroup>
+		<optgroup :label="$t('darkThemes')">
+			<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+		</optgroup>
+	</FormSelect>
+	<FormSelect v-model:value="darkTheme" v-else>
+		<template #label>{{ $t('themeForDarkMode') }}</template>
+		<optgroup :label="$t('darkThemes')">
+			<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+		</optgroup>
+		<optgroup :label="$t('lightThemes')">
+			<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+		</optgroup>
+	</FormSelect>
+
+	<FormGroup>
+		<div class="rfqxtzch _formItem _formPanel">
 			<div class="darkMode" :class="{ disabled: syncDeviceDarkMode }">
 				<div class="toggleWrapper">
 					<input type="checkbox" class="dn" id="dn" v-model="darkMode" :disabled="syncDeviceDarkMode"/>
@@ -23,85 +42,47 @@
 				</div>
 			</div>
 		</div>
-		<div class="_content">
-			<MkSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</MkSwitch>
-		</div>
-	</div>
-	<div class="_card _vMargin">
-		<div class="_content">
-			<MkSelect v-model:value="lightTheme">
-				<template #label>{{ $t('themeForLightMode') }}</template>
-				<optgroup :label="$t('lightThemes')">
-					<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
-				</optgroup>
-				<optgroup :label="$t('darkThemes')">
-					<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
-				</optgroup>
-			</MkSelect>
-			<MkSelect v-model:value="darkTheme">
-				<template #label>{{ $t('themeForDarkMode') }}</template>
-				<optgroup :label="$t('darkThemes')">
-					<option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
-				</optgroup>
-				<optgroup :label="$t('lightThemes')">
-					<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
-				</optgroup>
-			</MkSelect>
-			<a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<MkA to="/theme-editor" class="_link">{{ $t('_theme.make') }}</MkA>
-		</div>
-		<div class="_content">
-			<MkButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</MkButton>
-			<MkButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</MkButton>
-		</div>
-	</div>
-	<div class="_card _vMargin">
-		<div class="_title"><Fa :icon="faDownload"/> {{ $t('_theme.install') }}</div>
-		<div class="_content">
-			<MkTextarea v-model:value="installThemeCode">
-				<span>{{ $t('_theme.code') }}</span>
-			</MkTextarea>
-			<MkButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</MkButton>
-			<MkButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton>
-		</div>
-	</div>
-	<div class="_card _vMargin">
-		<div class="_title"><Fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</div>
-		<div class="_content">
-			<MkSelect v-model:value="selectedThemeId">
-				<option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
-			</MkSelect>
-			<template v-if="selectedTheme">
-				<MkTextarea readonly tall :value="selectedThemeCode">
-					<span>{{ $t('_theme.code') }}</span>
-					<template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template>
-				</MkTextarea>
-				<MkButton @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton>
-			</template>
-		</div>
-	</div>
-</div>
+		<FormSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</FormSwitch>
+	</FormGroup>
+
+	<FormButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</FormButton>
+	<FormButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</FormButton>
+
+	<FormGroup>
+		<FormLink to="https://assets.msky.cafe/theme/list" external>{{ $t('_theme.explore') }}</FormLink>
+		<FormLink to="/theme-editor">{{ $t('_theme.make') }}</FormLink>
+	</FormGroup>
+
+	<FormLink to="/settings/theme/install"><template #icon><Fa :icon="faDownload"/></template>{{ $t('_theme.install') }}</FormLink>
+
+	<FormLink to="/settings/theme/manage"><template #icon><Fa :icon="faFolderOpen"/></template>{{ $t('_theme.manage') }}</FormLink>
+</FormBase>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
-import * as JSON5 from 'json5';
-import MkButton from '@/components/ui/button.vue';
-import MkSelect from '@/components/ui/select.vue';
-import MkSwitch from '@/components/ui/switch.vue';
-import MkTextarea from '@/components/ui/textarea.vue';
-import { Theme, builtinThemes, applyTheme, validateTheme } from '@/scripts/theme';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/form/base.vue';
+import FormGroup from '@/components/form/group.vue';
+import FormLink from '@/components/form/link.vue';
+import FormButton from '@/components/form/button.vue';
+import { Theme, builtinThemes, applyTheme } from '@/scripts/theme';
 import { selectFile } from '@/scripts/select-file';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
 import * as os from '@/os';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkSelect,
-		MkSwitch,
-		MkTextarea,
+		FormSwitch,
+		FormSelect,
+		FormRadios,
+		FormBase,
+		FormGroup,
+		FormLink,
+		FormButton,
 	},
 
 	emits: ['info'],
@@ -113,8 +94,6 @@ export default defineComponent({
 				icon: faPalette
 			},
 			builtinThemes,
-			installThemeCode: null,
-			selectedThemeId: null,
 			wallpaper: localStorage.getItem('wallpaper'),
 			faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye
 		}
@@ -156,16 +135,6 @@ export default defineComponent({
 			get() { return this.$store.state.device.syncDeviceDarkMode; },
 			set(value) { this.$store.commit('device/set', { key: 'syncDeviceDarkMode', value }); }
 		},
-
-		selectedTheme() {
-			if (this.selectedThemeId == null) return null;
-			return this.themes.find(x => x.id === this.selectedThemeId);
-		},
-
-		selectedThemeCode() {
-			if (this.selectedTheme == null) return null;
-			return JSON5.stringify(this.selectedTheme, null, '\t');
-		},
 	},
 
 	watch: {
@@ -207,292 +176,230 @@ export default defineComponent({
 				this.wallpaper = file.url;
 			});
 		},
-
-		copyThemeCode() {
-			copyToClipboard(this.selectedThemeCode);
-			os.success();
-		},
-
-		parseThemeCode(code) {
-			let theme;
-
-			try {
-				theme = JSON5.parse(code);
-			} catch (e) {
-				os.dialog({
-					type: 'error',
-					text: this.$t('_theme.invalid')
-				});
-				return false;
-			}
-			if (!validateTheme(theme)) {
-				os.dialog({
-					type: 'error',
-					text: this.$t('_theme.invalid')
-				});
-				return false;
-			}
-			if (this.$store.state.device.themes.some(t => t.id === theme.id)) {
-				os.dialog({
-					type: 'info',
-					text: this.$t('_theme.alreadyInstalled')
-				});
-				return false;
-			}
-
-			return theme;
-		},
-
-		preview(code) {
-			const theme = this.parseThemeCode(code);
-			if (theme) applyTheme(theme, false);
-		},
-
-		install(code) {
-			const theme = this.parseThemeCode(code);
-			if (!theme) return;
-			const themes = this.$store.state.device.themes.concat(theme);
-			this.$store.commit('device/set', {
-				key: 'themes', value: themes
-			});
-			os.dialog({
-				type: 'success',
-				text: this.$t('_theme.installed', { name: theme.name })
-			});
-		},
-
-		uninstall() {
-			const theme = this.selectedTheme;
-			const themes = this.$store.state.device.themes.filter(t => t.id != theme.id);
-			this.$store.commit('device/set', {
-				key: 'themes', value: themes
-			});
-			os.success();
-		},
 	}
 });
 </script>
 
 <style lang="scss" scoped>
 .rfqxtzch {
-	> ._content {
-		> .darkMode {
-			position: relative;
-			padding: 32px 0;
+	padding: 16px;
 
-			&.disabled {
-				opacity: 0.7;
+	> .darkMode {
+		position: relative;
+		padding: 32px 0;
 
-				&, * {
-					cursor: not-allowed !important;
-				}
+		&.disabled {
+			opacity: 0.7;
+
+			&, * {
+				cursor: not-allowed !important;
 			}
+		}
 
-			.toggleWrapper {
+		.toggleWrapper {
+			position: absolute;
+			top: 50%;
+			left: 50%;
+			overflow: hidden;
+			padding: 0 100px;
+			transform: translate3d(-50%, -50%, 0);
+
+			input {
 				position: absolute;
-				top: 50%;
-				left: 50%;
-				overflow: hidden;
-				padding: 0 100px;
-				transform: translate3d(-50%, -50%, 0);
+				left: -99em;
+			}
+		}
 
-				input {
-					position: absolute;
-					left: -99em;
-				}
+		.toggle {
+			cursor: pointer;
+			display: inline-block;
+			position: relative;
+			width: 90px;
+			height: 50px;
+			background-color: #83D8FF;
+			border-radius: 90px - 6;
+			transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+
+			> .before, > .after {
+				position: absolute;
+				top: 15px;
+				font-size: 18px;
+				transition: color 1s ease;
 			}
 
-			.toggle {
-				cursor: pointer;
-				display: inline-block;
-				position: relative;
-				width: 90px;
-				height: 50px;
-				background-color: #83D8FF;
-				border-radius: 90px - 6;
-				transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+			> .before {
+				left: -70px;
+				color: var(--accent);
+			}
 
-				> .before, > .after {
-					position: absolute;
-					top: 15px;
-					font-size: 18px;
-					transition: color 1s ease;
-				}
+			> .after {
+				right: -68px;
+				color: var(--fg);
+			}
+		}
+
+		.toggle__handler {
+			display: inline-block;
+			position: relative;
+			z-index: 1;
+			top: 3px;
+			left: 3px;
+			width: 50px - 6;
+			height: 50px - 6;
+			background-color: #FFCF96;
+			border-radius: 50px;
+			box-shadow: 0 2px 6px rgba(0,0,0,.3);
+			transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
+			transform:  rotate(-45deg);
+
+			.crater {
+				position: absolute;
+				background-color: #E8CDA5;
+				opacity: 0;
+				transition: opacity 200ms ease-in-out !important;
+				border-radius: 100%;
+			}
+
+			.crater--1 {
+				top: 18px;
+				left: 10px;
+				width: 4px;
+				height: 4px;
+			}
+
+			.crater--2 {
+				top: 28px;
+				left: 22px;
+				width: 6px;
+				height: 6px;
+			}
+
+			.crater--3 {
+				top: 10px;
+				left: 25px;
+				width: 8px;
+				height: 8px;
+			}
+		}
+
+		.star {
+			position: absolute;
+			background-color: #ffffff;
+			transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+			border-radius: 50%;
+		}
+
+		.star--1 {
+			top: 10px;
+			left: 35px;
+			z-index: 0;
+			width: 30px;
+			height: 3px;
+		}
+
+		.star--2 {
+			top: 18px;
+			left: 28px;
+			z-index: 1;
+			width: 30px;
+			height: 3px;
+		}
+
+		.star--3 {
+			top: 27px;
+			left: 40px;
+			z-index: 0;
+			width: 30px;
+			height: 3px;
+		}
+
+		.star--4,
+		.star--5,
+		.star--6 {
+			opacity: 0;
+			transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+		}
+
+		.star--4 {
+			top: 16px;
+			left: 11px;
+			z-index: 0;
+			width: 2px;
+			height: 2px;
+			transform: translate3d(3px,0,0);
+		}
+
+		.star--5 {
+			top: 32px;
+			left: 17px;
+			z-index: 0;
+			width: 3px;
+			height: 3px;
+			transform: translate3d(3px,0,0);
+		}
+
+		.star--6 {
+			top: 36px;
+			left: 28px;
+			z-index: 0;
+			width: 2px;
+			height: 2px;
+			transform: translate3d(3px,0,0);
+		}
+
+		input:checked {
+			+ .toggle {
+				background-color: #749DD6;
 
 				> .before {
-					left: -70px;
-					color: var(--accent);
+					color: var(--fg);
 				}
 
 				> .after {
-					right: -68px;
-					color: var(--fg);
-				}
-			}
-
-			.toggle__handler {
-				display: inline-block;
-				position: relative;
-				z-index: 1;
-				top: 3px;
-				left: 3px;
-				width: 50px - 6;
-				height: 50px - 6;
-				background-color: #FFCF96;
-				border-radius: 50px;
-				box-shadow: 0 2px 6px rgba(0,0,0,.3);
-				transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
-				transform:  rotate(-45deg);
-
-				.crater {
-					position: absolute;
-					background-color: #E8CDA5;
-					opacity: 0;
-					transition: opacity 200ms ease-in-out !important;
-					border-radius: 100%;
+					color: var(--accent);
 				}
 
-				.crater--1 {
-					top: 18px;
-					left: 10px;
+				.toggle__handler {
+					background-color: #FFE5B5;
+					transform: translate3d(40px, 0, 0) rotate(0);
+
+					.crater { opacity: 1; }
+				}
+
+				.star--1 {
+					width: 2px;
+					height: 2px;
+				}
+
+				.star--2 {
 					width: 4px;
 					height: 4px;
+					transform: translate3d(-5px, 0, 0);
 				}
 
-				.crater--2 {
-					top: 28px;
-					left: 22px;
-					width: 6px;
-					height: 6px;
+				.star--3 {
+					width: 2px;
+					height: 2px;
+					transform: translate3d(-7px, 0, 0);
 				}
 
-				.crater--3 {
-					top: 10px;
-					left: 25px;
-					width: 8px;
-					height: 8px;
+				.star--4,
+				.star--5,
+				.star--6 {
+					opacity: 1;
+					transform: translate3d(0,0,0);
 				}
-			}
 
-			.star {
-				position: absolute;
-				background-color: #ffffff;
-				transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-				border-radius: 50%;
-			}
+				.star--4 {
+					transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+				}
 
-			.star--1 {
-				top: 10px;
-				left: 35px;
-				z-index: 0;
-				width: 30px;
-				height: 3px;
-			}
+				.star--5 {
+					transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+				}
 
-			.star--2 {
-				top: 18px;
-				left: 28px;
-				z-index: 1;
-				width: 30px;
-				height: 3px;
-			}
-
-			.star--3 {
-				top: 27px;
-				left: 40px;
-				z-index: 0;
-				width: 30px;
-				height: 3px;
-			}
-
-			.star--4,
-			.star--5,
-			.star--6 {
-				opacity: 0;
-				transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-			}
-
-			.star--4 {
-				top: 16px;
-				left: 11px;
-				z-index: 0;
-				width: 2px;
-				height: 2px;
-				transform: translate3d(3px,0,0);
-			}
-
-			.star--5 {
-				top: 32px;
-				left: 17px;
-				z-index: 0;
-				width: 3px;
-				height: 3px;
-				transform: translate3d(3px,0,0);
-			}
-
-			.star--6 {
-				top: 36px;
-				left: 28px;
-				z-index: 0;
-				width: 2px;
-				height: 2px;
-				transform: translate3d(3px,0,0);
-			}
-
-			input:checked {
-				+ .toggle {
-					background-color: #749DD6;
-
-					> .before {
-						color: var(--fg);
-					}
-
-					> .after {
-						color: var(--accent);
-					}
-
-					.toggle__handler {
-						background-color: #FFE5B5;
-						transform: translate3d(40px, 0, 0) rotate(0);
-
-						.crater { opacity: 1; }
-					}
-
-					.star--1 {
-						width: 2px;
-						height: 2px;
-					}
-
-					.star--2 {
-						width: 4px;
-						height: 4px;
-						transform: translate3d(-5px, 0, 0);
-					}
-
-					.star--3 {
-						width: 2px;
-						height: 2px;
-						transform: translate3d(-7px, 0, 0);
-					}
-
-					.star--4,
-					.star--5,
-					.star--6 {
-						opacity: 1;
-						transform: translate3d(0,0,0);
-					}
-
-					.star--4 {
-						transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-					}
-
-					.star--5 {
-						transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-					}
-
-					.star--6 {
-						transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
-					}
+				.star--6 {
+					transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
 				}
 			}
 		}
diff --git a/src/client/pages/settings/word-mute.vue b/src/client/pages/settings/word-mute.vue
index 444b2e598c..3148c635bc 100644
--- a/src/client/pages/settings/word-mute.vue
+++ b/src/client/pages/settings/word-mute.vue
@@ -1,47 +1,53 @@
 <template>
-<div class="_section">
-	<div class="_card">
-		<MkTab v-model:value="tab">
-			<option value="soft">{{ $t('_wordMute.soft') }}</option>
-			<option value="hard">{{ $t('_wordMute.hard') }}</option>
-		</MkTab>
-		<div class="_content">
+<div>
+	<MkTab v-model:value="tab">
+		<option value="soft">{{ $t('_wordMute.soft') }}</option>
+		<option value="hard">{{ $t('_wordMute.hard') }}</option>
+	</MkTab>
+	<FormBase>
+		<div class="_formItem">
 			<div v-show="tab === 'soft'">
 				<MkInfo>{{ $t('_wordMute.softDescription') }}</MkInfo>
-				<MkTextarea v-model:value="softMutedWords">
+				<FormTextarea v-model:value="softMutedWords">
 					<span>{{ $t('_wordMute.muteWords') }}</span>
 					<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
-				</MkTextarea>
+				</FormTextarea>
 			</div>
 			<div v-show="tab === 'hard'">
 				<MkInfo>{{ $t('_wordMute.hardDescription') }}</MkInfo>
-				<MkTextarea v-model:value="hardMutedWords" style="margin-bottom: 16px;">
+				<FormTextarea v-model:value="hardMutedWords">
 					<span>{{ $t('_wordMute.muteWords') }}</span>
 					<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
-				</MkTextarea>
-				<div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div>
+				</FormTextarea>
+				<FormKeyValueView v-if="hardWordMutedNotesCount != null">
+					<template #key>{{ $t('_wordMute.mutedNotes') }}</template>
+					<template #value>{{ number(hardWordMutedNotesCount) }}</template>
+				</FormKeyValueView>
 			</div>
 		</div>
-		<div class="_footer">
-			<MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton>
-		</div>
-	</div>
+		<FormButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</FormButton>
+	</FormBase>
 </div>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons';
-import MkButton from '@/components/ui/button.vue';
-import MkTextarea from '@/components/ui/textarea.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormBase from '@/components/form/base.vue';
+import FormKeyValueView from '@/components/form/key-value-view.vue';
+import FormButton from '@/components/form/button.vue';
 import MkTab from '@/components/tab.vue';
 import MkInfo from '@/components/ui/info.vue';
 import * as os from '@/os';
+import number from '@/filters/number';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkTextarea,
+		FormBase,
+		FormButton,
+		FormTextarea,
+		FormKeyValueView,
 		MkTab,
 		MkInfo,
 	},
@@ -97,6 +103,8 @@ export default defineComponent({
 			});
 			this.changed = false;
 		},
+
+		number
 	}
 });
 </script>
diff --git a/src/client/pages/user/follow-list.vue b/src/client/pages/user/follow-list.vue
index 6761210ff6..90a67e9a8e 100644
--- a/src/client/pages/user/follow-list.vue
+++ b/src/client/pages/user/follow-list.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="_section">
+<div>
 	<MkPagination :pagination="pagination" #default="{items}" class="mk-following-or-followers _content" ref="list">
 		<div class="users">
 			<MkUserInfo class="user" v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :user="user" :key="user.id"/>
diff --git a/src/client/pages/user/index.activity.vue b/src/client/pages/user/index.activity.vue
index 30c02ec54a..1f059146e3 100644
--- a/src/client/pages/user/index.activity.vue
+++ b/src/client/pages/user/index.activity.vue
@@ -1,15 +1,24 @@
 <template>
-<div>
-	<div ref="chart"></div>
-</div>
+<MkContainer>
+	<template #header><Fa :icon="faChartBar" style="margin-right: 0.5em;"/>{{ $t('activity') }}</template>
+
+	<div style="padding: 8px;">
+		<div ref="chart"></div>
+	</div>
+</MkContainer>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
 import ApexCharts from 'apexcharts';
+import { faChartBar } from '@fortawesome/free-solid-svg-icons';
 import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
 
 export default defineComponent({
+	components: {
+		MkContainer,
+	},
 	props: {
 		user: {
 			type: Object,
@@ -25,7 +34,8 @@ export default defineComponent({
 		return {
 			fetching: true,
 			data: [],
-			peak: null
+			peak: null,
+			faChartBar,
 		};
 	},
 	mounted() {
diff --git a/src/client/pages/user/index.photos.vue b/src/client/pages/user/index.photos.vue
index aabcbebe8a..7d498cfb30 100644
--- a/src/client/pages/user/index.photos.vue
+++ b/src/client/pages/user/index.photos.vue
@@ -1,29 +1,43 @@
 <template>
-<div class="ujigsodd">
-	<MkLoading v-if="fetching"/>
-	<div class="stream" v-if="!fetching && images.length > 0">
-		<MkA v-for="image in images"
-			class="img"
-			:style="`background-image: url(${thumbnail(image.file)})`"
-			:to="notePage(image.note)"
-		></MkA>
+<MkContainer>
+	<template #header><Fa :icon="faImage" style="margin-right: 0.5em;"/>{{ $t('images') }}</template>
+	<div class="ujigsodd">
+		<MkLoading v-if="fetching"/>
+		<div class="stream" v-if="!fetching && images.length > 0">
+			<MkA v-for="image in images"
+				class="img"
+				:style="`background-image: url(${thumbnail(image.file)})`"
+				:to="notePage(image.note)"
+			></MkA>
+		</div>
+		<p class="empty" v-if="!fetching && images.length == 0">{{ $t('nothing') }}</p>
 	</div>
-	<p class="empty" v-if="!fetching && images.length == 0">{{ $t('nothing') }}</p>
-</div>
+</MkContainer>
 </template>
 
 <script lang="ts">
 import { defineComponent } from 'vue';
+import { faImage } from '@fortawesome/free-solid-svg-icons';
 import { getStaticImageUrl } from '@/scripts/get-static-image-url';
 import notePage from '../../filters/note';
 import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
 
 export default defineComponent({
-	props: ['user'],
+	components: {
+		MkContainer,
+	},
+	props: {
+		user: {
+			type: Object,
+			required: true
+		},
+	},
 	data() {
 		return {
 			fetching: true,
-			images: []
+			images: [],
+			faImage
 		};
 	},
 	mounted() {
@@ -37,7 +51,7 @@ export default defineComponent({
 		os.api('users/notes', {
 			userId: this.user.id,
 			fileType: image,
-			excludeNsfw: !this.$store.state.device.alwaysShowNsfw,
+			excludeNsfw: this.$store.state.device.nsfw !== 'ignore',
 			limit: 9,
 		}).then(notes => {
 			for (const note of notes) {
@@ -66,6 +80,8 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .ujigsodd {
+	padding: 8px;
+
 	> .stream {
 		display: flex;
 		justify-content: center;
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
index 015d83f755..ceafa7ba97 100644
--- a/src/client/pages/user/index.vue
+++ b/src/client/pages/user/index.vue
@@ -1,115 +1,113 @@
 <template>
-<div class="mk-user-page" v-if="user" v-size="{ max: [500] }">
-	<!-- TODO -->
-	<!-- <div class="punished" v-if="user.isSuspended"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSuspended') }}</div> -->
-	<!-- <div class="punished" v-if="user.isSilenced"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSilenced') }}</div> -->
+<div>
+	<div class="mk-user-page" v-if="user" v-size="{ max: [500] }" :class="{ _section: narrow === false }">
+		<!-- TODO -->
+		<!-- <div class="punished" v-if="user.isSuspended"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSuspended') }}</div> -->
+		<!-- <div class="punished" v-if="user.isSilenced"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSilenced') }}</div> -->
 
-	<div class="profile _section _fitBottom">
-		<MkRemoteCaution v-if="user.host != null" :href="user.url" class="_content _vMargin"/>
+		<div class="main">
+			<div class="profile _vMargin" :class="{ _section: narrow === true }">
+				<MkRemoteCaution v-if="user.host != null" :href="user.url" class="_content _vMargin"/>
 
-		<div class="_content _vMargin" :key="user.id">
-			<div class="banner-container" :style="style">
-				<div class="banner" ref="banner" :style="style"></div>
-				<div class="fade"></div>
-				<div class="title">
-					<MkUserName class="name" :user="user" :nowrap="true"/>
-					<div class="bottom">
-						<span class="username"><MkAcct :user="user" :detail="true" /></span>
-						<span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><Fa :icon="faBookmark"/></span>
-						<span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><Fa :icon="farBookmark"/></span>
-						<span v-if="user.isLocked" :title="$t('isLocked')"><Fa :icon="faLock"/></span>
-						<span v-if="user.isBot" :title="$t('isBot')"><Fa :icon="faRobot"/></span>
+				<div class="_content _panel _vMargin" :key="user.id">
+					<div class="banner-container" :style="style">
+						<div class="banner" ref="banner" :style="style"></div>
+						<div class="fade"></div>
+						<div class="title">
+							<MkUserName class="name" :user="user" :nowrap="true"/>
+							<div class="bottom">
+								<span class="username"><MkAcct :user="user" :detail="true" /></span>
+								<span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><Fa :icon="faBookmark"/></span>
+								<span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><Fa :icon="farBookmark"/></span>
+								<span v-if="user.isLocked" :title="$t('isLocked')"><Fa :icon="faLock"/></span>
+								<span v-if="user.isBot" :title="$t('isBot')"><Fa :icon="faRobot"/></span>
+							</div>
+						</div>
+						<span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('followsYou') }}</span>
+						<div class="actions" v-if="$store.getters.isSignedIn">
+							<button @click="menu" class="menu _button"><Fa :icon="faEllipsisH"/></button>
+							<MkFollowButton v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+						</div>
+					</div>
+					<MkAvatar class="avatar" :user="user" :disable-preview="true"/>
+					<div class="title">
+						<MkUserName :user="user" :nowrap="false" class="name"/>
+						<div class="bottom">
+							<span class="username"><MkAcct :user="user" :detail="true" /></span>
+							<span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><Fa :icon="faBookmark"/></span>
+							<span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><Fa :icon="farBookmark"/></span>
+							<span v-if="user.isLocked" :title="$t('isLocked')"><Fa :icon="faLock"/></span>
+							<span v-if="user.isBot" :title="$t('isBot')"><Fa :icon="faRobot"/></span>
+						</div>
+					</div>
+					<div class="description">
+						<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+						<p v-else class="empty">{{ $t('noAccountDescription') }}</p>
+					</div>
+					<div class="fields system">
+						<dl class="field" v-if="user.location">
+							<dt class="name"><Fa :icon="faMapMarker" fixed-width/> {{ $t('location') }}</dt>
+							<dd class="value">{{ user.location }}</dd>
+						</dl>
+						<dl class="field" v-if="user.birthday">
+							<dt class="name"><Fa :icon="faBirthdayCake" fixed-width/> {{ $t('birthday') }}</dt>
+							<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+						</dl>
+						<dl class="field">
+							<dt class="name"><Fa :icon="faCalendarAlt" fixed-width/> {{ $t('registeredDate') }}</dt>
+							<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+						</dl>
+					</div>
+					<div class="fields" v-if="user.fields.length > 0">
+						<dl class="field" v-for="(field, i) in user.fields" :key="i">
+							<dt class="name">
+								<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+							</dt>
+							<dd class="value">
+								<Mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/>
+							</dd>
+						</dl>
+					</div>
+					<div class="status">
+						<MkA :to="userPage(user)" :class="{ active: page === 'index' }">
+							<b>{{ number(user.notesCount) }}</b>
+							<span>{{ $t('notes') }}</span>
+						</MkA>
+						<MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+							<b>{{ number(user.followingCount) }}</b>
+							<span>{{ $t('following') }}</span>
+						</MkA>
+						<MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+							<b>{{ number(user.followersCount) }}</b>
+							<span>{{ $t('followers') }}</span>
+						</MkA>
 					</div>
 				</div>
-				<span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('followsYou') }}</span>
-				<div class="actions" v-if="$store.getters.isSignedIn">
-					<button @click="menu" class="menu _button"><Fa :icon="faEllipsisH"/></button>
-					<MkFollowButton v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+			</div>
+
+			<template v-if="page === 'index'">
+				<div v-if="user.pinnedNotes.length > 0" :class="{ _section: narrow === true, _vMargin: narrow === false }">
+					<XNote v-for="note in user.pinnedNotes" class="note _content _vMargin" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :detail="true" :pinned="true"/>
 				</div>
-			</div>
-			<MkAvatar class="avatar" :user="user" :disable-preview="true"/>
-			<div class="title">
-				<MkUserName :user="user" :nowrap="false" class="name"/>
-				<div class="bottom">
-					<span class="username"><MkAcct :user="user" :detail="true" /></span>
-					<span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><Fa :icon="faBookmark"/></span>
-					<span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><Fa :icon="farBookmark"/></span>
-					<span v-if="user.isLocked" :title="$t('isLocked')"><Fa :icon="faLock"/></span>
-					<span v-if="user.isBot" :title="$t('isBot')"><Fa :icon="faRobot"/></span>
+				<div v-if="narrow === true" class="_section">
+					<XPhotos class="_content _vMargin" :user="user" :key="user.id"/>
+					<XActivity class="_content _vMargin" :user="user" :key="user.id"/>
 				</div>
-			</div>
-			<div class="description">
-				<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
-				<p v-else class="empty">{{ $t('noAccountDescription') }}</p>
-			</div>
-			<div class="fields system">
-				<dl class="field" v-if="user.location">
-					<dt class="name"><Fa :icon="faMapMarker" fixed-width/> {{ $t('location') }}</dt>
-					<dd class="value">{{ user.location }}</dd>
-				</dl>
-				<dl class="field" v-if="user.birthday">
-					<dt class="name"><Fa :icon="faBirthdayCake" fixed-width/> {{ $t('birthday') }}</dt>
-					<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
-				</dl>
-				<dl class="field">
-					<dt class="name"><Fa :icon="faCalendarAlt" fixed-width/> {{ $t('registeredDate') }}</dt>
-					<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
-				</dl>
-			</div>
-			<div class="fields" v-if="user.fields.length > 0">
-				<dl class="field" v-for="(field, i) in user.fields" :key="i">
-					<dt class="name">
-						<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
-					</dt>
-					<dd class="value">
-						<Mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/>
-					</dd>
-				</dl>
-			</div>
-			<div class="status">
-				<MkA :to="userPage(user)" :class="{ active: page === 'index' }">
-					<b>{{ number(user.notesCount) }}</b>
-					<span>{{ $t('notes') }}</span>
-				</MkA>
-				<MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
-					<b>{{ number(user.followingCount) }}</b>
-					<span>{{ $t('following') }}</span>
-				</MkA>
-				<MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
-					<b>{{ number(user.followersCount) }}</b>
-					<span>{{ $t('followers') }}</span>
-				</MkA>
-			</div>
+				<div :class="{ _section: narrow === true, _vMargin: narrow === false }">
+					<XUserTimeline :user="user" class="_content"/>
+				</div>
+			</template>
+			<XFollowList v-else-if="page === 'following'" :class="{ _section: narrow === true, _vMargin: narrow === false }" type="following" :user="user"/>
+			<XFollowList v-else-if="page === 'followers'" :class="{ _section: narrow === true, _vMargin: narrow === false }" type="followers" :user="user"/>
+		</div>
+		<div class="side" v-if="narrow === false">
+			<XPhotos class="_vMargin" :user="user" :key="user.id"/>
+			<XActivity class="_vMargin" :user="user" :key="user.id"/>
 		</div>
 	</div>
-
-	<template v-if="page === 'index'">
-		<div class="_section">
-			<div class="_content _vMargin" v-if="user.pinnedNotes.length > 0">
-				<XNote v-for="note in user.pinnedNotes" class="note _vMargin" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :detail="true" :pinned="true"/>
-			</div>
-			<MkFolder :body-togglable="true" class="_content _vMargin" persist-key="user-images">
-				<template #header><Fa :icon="faImage" style="margin-right: 0.5em;"/>{{ $t('images') }}</template>
-				<div>
-					<XPhotos :user="user" :key="user.id"/>
-				</div>
-			</MkFolder>
-			<MkFolder :body-togglable="true" class="_content _vMargin" persist-key="user-activity">
-				<template #header><Fa :icon="faChartBar" style="margin-right: 0.5em;"/>{{ $t('activity') }}</template>
-				<div>
-					<XActivity :user="user" :key="user.id"/>
-				</div>
-			</MkFolder>
-		</div>
-		<div class="_section">
-			<XUserTimeline :user="user" class="_content"/>
-		</div>
-	</template>
-	<XFollowList v-else-if="page === 'following'" type="following" :user="user"/>
-	<XFollowList v-else-if="page === 'followers'" type="followers" :user="user"/>
-</div>
-<div v-else-if="error">
-	<MkError @retry="fetch()"/>
+	<div v-else-if="error">
+		<MkError @retry="fetch()"/>
+	</div>
 </div>
 </template>
 
@@ -170,6 +168,7 @@ export default defineComponent({
 			user: null,
 			error: null,
 			parallaxAnimationId: null,
+			narrow: null,
 			faExclamationTriangle, faEllipsisH, faRobot, faLock, faBookmark, farBookmark, faChartBar, faImage, faBirthdayCake, faMapMarker, faCalendarAlt
 		};
 	},
@@ -197,6 +196,7 @@ export default defineComponent({
 
 	mounted() {
 		window.requestAnimationFrame(this.parallaxLoop);
+		this.narrow = this.$el.clientWidth < 1000;
 	},
 
 	beforeUnmount() {
@@ -254,220 +254,234 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .mk-user-page {
-	> .punished {
-		font-size: 0.8em;
-		padding: 16px;
-	}
+	display: flex;
+	max-width: 1050px;
+	margin: 0 auto;
+	
+	> .main {
+		flex: 1;
 
-	> .profile {
-		> ._content {
-			position: relative;
-			overflow: hidden;
+		> .punished {
+			font-size: 0.8em;
+			padding: 16px;
+		}
 
-			> .banner-container {
+		> .profile {
+			> ._content {
 				position: relative;
-				height: 250px;
 				overflow: hidden;
-				background-size: cover;
-				background-position: center;
-				border-radius: 12px;
 
-				> .banner {
-					height: 100%;
-					background-color: #4c5e6d;
+				> .banner-container {
+					position: relative;
+					height: 250px;
+					overflow: hidden;
 					background-size: cover;
 					background-position: center;
-					box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
-					will-change: background-position;
-				}
 
-				> .fade {
-					position: absolute;
-					bottom: 0;
-					left: 0;
-					width: 100%;
-					height: 78px;
-					background: linear-gradient(transparent, rgba(#000, 0.7));
-				}
+					> .banner {
+						height: 100%;
+						background-color: #4c5e6d;
+						background-size: cover;
+						background-position: center;
+						box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
+						will-change: background-position;
+					}
 
-				> .followed {
-					position: absolute;
-					top: 12px;
-					left: 12px;
-					padding: 4px 8px;
-					color: #fff;
-					background: rgba(0, 0, 0, 0.7);
-					font-size: 0.7em;
-					border-radius: 6px;
-				}
+					> .fade {
+						position: absolute;
+						bottom: 0;
+						left: 0;
+						width: 100%;
+						height: 78px;
+						background: linear-gradient(transparent, rgba(#000, 0.7));
+					}
 
-				> .actions {
-					position: absolute;
-					top: 12px;
-					right: 12px;
-					-webkit-backdrop-filter: blur(8px);
-					backdrop-filter: blur(8px);
-					background: rgba(0, 0, 0, 0.2);
-					padding: 8px;
-					border-radius: 24px;
-
-					> .menu {
-						vertical-align: bottom;
-						height: 31px;
-						width: 31px;
+					> .followed {
+						position: absolute;
+						top: 12px;
+						left: 12px;
+						padding: 4px 8px;
 						color: #fff;
-						text-shadow: 0 0 8px #000;
-						font-size: 16px;
+						background: rgba(0, 0, 0, 0.7);
+						font-size: 0.7em;
+						border-radius: 6px;
 					}
 
-					> .koudoku {
-						margin-left: 4px;
-						vertical-align: bottom;
-					}
-				}
+					> .actions {
+						position: absolute;
+						top: 12px;
+						right: 12px;
+						-webkit-backdrop-filter: blur(8px);
+						backdrop-filter: blur(8px);
+						background: rgba(0, 0, 0, 0.2);
+						padding: 8px;
+						border-radius: 24px;
 
-				> .title {
-					position: absolute;
-					bottom: 0;
-					left: 0;
-					width: 100%;
-					padding: 0 0 8px 154px;
-					box-sizing: border-box;
-					color: #fff;
+						> .menu {
+							vertical-align: bottom;
+							height: 31px;
+							width: 31px;
+							color: #fff;
+							text-shadow: 0 0 8px #000;
+							font-size: 16px;
+						}
 
-					> .name {
-						display: block;
-						margin: 0;
-						line-height: 32px;
-						font-weight: bold;
-						font-size: 1.8em;
-						text-shadow: 0 0 8px #000;
+						> .koudoku {
+							margin-left: 4px;
+							vertical-align: bottom;
+						}
 					}
 
-					> .bottom {
-						> * {
-							display: inline-block;
-							margin-right: 16px;
-							line-height: 20px;
-							opacity: 0.8;
+					> .title {
+						position: absolute;
+						bottom: 0;
+						left: 0;
+						width: 100%;
+						padding: 0 0 8px 154px;
+						box-sizing: border-box;
+						color: #fff;
 
-							&.username {
-								font-weight: bold;
+						> .name {
+							display: block;
+							margin: 0;
+							line-height: 32px;
+							font-weight: bold;
+							font-size: 1.8em;
+							text-shadow: 0 0 8px #000;
+						}
+
+						> .bottom {
+							> * {
+								display: inline-block;
+								margin-right: 16px;
+								line-height: 20px;
+								opacity: 0.8;
+
+								&.username {
+									font-weight: bold;
+								}
 							}
 						}
 					}
 				}
-			}
 
-			> .title {
-				display: none;
-				text-align: center;
-				padding: 50px 8px 16px 8px;
-				font-weight: bold;
-				border-bottom: solid 1px var(--divider);
-
-				> .bottom {
-					> * {
-						display: inline-block;
-						margin-right: 8px;
-						opacity: 0.8;
-					}
-				}
-			}
-
-			> .avatar {
-				display: block;
-				position: absolute;
-				top: 170px;
-				left: 16px;
-				z-index: 2;
-				width: 120px;
-				height: 120px;
-				box-shadow: 1px 1px 3px rgba(#000, 0.2);
-			}
-
-			> .description {
-				padding: 24px 24px 24px 154px;
-				font-size: 0.95em;
-
-				> .empty {
-					margin: 0;
-					opacity: 0.5;
-				}
-			}
-
-			> .fields {
-				padding: 24px;
-				font-size: 0.9em;
-				border-top: solid 1px var(--divider);
-
-				> .field {
-					display: flex;
-					padding: 0;
-					margin: 0;
-					align-items: center;
-
-					&:not(:last-child) {
-						margin-bottom: 8px;
-					}
-
-					> .name {
-						width: 30%;
-						overflow: hidden;
-						white-space: nowrap;
-						text-overflow: ellipsis;
-						font-weight: bold;
-						text-align: center;
-					}
-
-					> .value {
-						width: 70%;
-						overflow: hidden;
-						white-space: nowrap;
-						text-overflow: ellipsis;
-					}
-				}
-
-				&.system > .field > .name {
-				}
-			}
-
-			> .status {
-				display: flex;
-				padding: 24px;
-				border-top: solid 1px var(--divider);
-
-				> a {
-					flex: 1;
+				> .title {
+					display: none;
 					text-align: center;
+					padding: 50px 8px 16px 8px;
+					font-weight: bold;
+					border-bottom: solid 1px var(--divider);
 
-					&.active {
-						color: var(--accent);
+					> .bottom {
+						> * {
+							display: inline-block;
+							margin-right: 8px;
+							opacity: 0.8;
+						}
+					}
+				}
+
+				> .avatar {
+					display: block;
+					position: absolute;
+					top: 170px;
+					left: 16px;
+					z-index: 2;
+					width: 120px;
+					height: 120px;
+					box-shadow: 1px 1px 3px rgba(#000, 0.2);
+				}
+
+				> .description {
+					padding: 24px 24px 24px 154px;
+					font-size: 0.95em;
+
+					> .empty {
+						margin: 0;
+						opacity: 0.5;
+					}
+				}
+
+				> .fields {
+					padding: 24px;
+					font-size: 0.9em;
+					border-top: solid 1px var(--divider);
+
+					> .field {
+						display: flex;
+						padding: 0;
+						margin: 0;
+						align-items: center;
+
+						&:not(:last-child) {
+							margin-bottom: 8px;
+						}
+
+						> .name {
+							width: 30%;
+							overflow: hidden;
+							white-space: nowrap;
+							text-overflow: ellipsis;
+							font-weight: bold;
+							text-align: center;
+						}
+
+						> .value {
+							width: 70%;
+							overflow: hidden;
+							white-space: nowrap;
+							text-overflow: ellipsis;
+						}
 					}
 
-					&:hover {
-						text-decoration: none;
+					&.system > .field > .name {
 					}
+				}
 
-					> b {
-						display: block;
-						line-height: 16px;
-					}
+				> .status {
+					display: flex;
+					padding: 24px;
+					border-top: solid 1px var(--divider);
 
-					> span {
-						font-size: 70%;
+					> a {
+						flex: 1;
+						text-align: center;
+
+						&.active {
+							color: var(--accent);
+						}
+
+						&:hover {
+							text-decoration: none;
+						}
+
+						> b {
+							display: block;
+							line-height: 16px;
+						}
+
+						> span {
+							font-size: 70%;
+						}
 					}
 				}
 			}
 		}
+
+		> .content {
+			margin-bottom: var(--margin);
+		}
 	}
 
-	> .content {
-		margin-bottom: var(--margin);
+	> .side {
+		flex-basis: 300px;
+		margin-left: var(--margin);
 	}
 
 	&.max-width_500px {
-		> .profile > ._content {
+		display: block;
+
+		> .main > .profile > ._content {
 			> .banner-container {
 				height: 140px;
 
diff --git a/src/client/pages/welcome.entrance.vue b/src/client/pages/welcome.entrance.vue
index b1cd6d50c6..d5ea47bb85 100644
--- a/src/client/pages/welcome.entrance.vue
+++ b/src/client/pages/welcome.entrance.vue
@@ -1,11 +1,5 @@
 <template>
 <div class="rsqzvsbo _section" v-if="meta">
-	<div class="about">
-		<h1>{{ instanceName }}</h1>
-		<div class="desc" v-html="meta.description || $t('introMisskey')"></div>
-		<MkButton @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</MkButton>
-		<MkButton @click="signin()" style="display: inline-block;">{{ $t('login') }}</MkButton>
-	</div>
 	<div class="blocks">
 		<XBlock class="block" v-for="path in meta.pinnedPages" :initial-path="path" :key="path"/>
 	</div>
@@ -68,28 +62,6 @@ export default defineComponent({
 .rsqzvsbo {
 	text-align: center;
 
-	> .about {
-		display: inline-block;
-		padding: 24px;
-		margin-bottom: var(--margin);
-		-webkit-backdrop-filter: blur(8px);
-		backdrop-filter: blur(8px);
-		background: rgba(0, 0, 0, 0.5);
-		border-radius: var(--radius);
-		text-align: center;
-		box-sizing: border-box;
-		min-width: 300px;
-		max-width: 800px;
-
-		&, * {
-			color: #fff !important;
-		}
-
-		> h1 {
-			margin: 0 0 16px 0;
-		}
-	}
-
 	> .blocks {
 		display: grid;
 		grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
diff --git a/src/client/router.ts b/src/client/router.ts
index 5ad3345d55..a21c6494b9 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -22,7 +22,7 @@ export const router = createRouter({
 		{ path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) },
 		{ path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) },
 		{ path: '/@:acct/room', props: true, component: page('room/room') },
-		{ path: '/settings/:page?', name: 'settings', component: page('settings/index'), props: route => ({ page: route.params.page || null }) },
+		{ path: '/settings/:page(.*)?', name: 'settings', component: page('settings/index'), props: route => ({ page: route.params.page || null }) },
 		{ path: '/announcements', component: page('announcements') },
 		{ path: '/about', component: page('about') },
 		{ path: '/about-misskey', component: page('about-misskey') },
@@ -57,7 +57,6 @@ export const router = createRouter({
 		{ path: '/my/groups/:group', component: page('my-groups/group') },
 		{ path: '/my/antennas', component: page('my-antennas/index') },
 		{ path: '/my/clips', component: page('my-clips/index') },
-		{ path: '/my/apps', component: page('apps') },
 		{ path: '/scratchpad', component: page('scratchpad') },
 		{ path: '/instance', component: page('instance/index') },
 		{ path: '/instance/emojis', component: page('instance/emojis') },
diff --git a/src/client/scripts/sound.ts b/src/client/scripts/sound.ts
new file mode 100644
index 0000000000..13fd9a80f5
--- /dev/null
+++ b/src/client/scripts/sound.ts
@@ -0,0 +1,24 @@
+import { device } from '@/cold-storage';
+
+const cache = new Map<string, HTMLAudioElement>();
+
+export function play(type: string) {
+	const sound = device.get('sound_' + type as any);
+	if (sound.type == null) return;
+	playFile(sound.type, sound.volume);
+}
+
+export function playFile(file: string, volume: number) {
+	const masterVolume = device.get('sound_masterVolume');
+	if (masterVolume === 0) return;
+
+	let audio: HTMLAudioElement;
+	if (cache.has(file)) {
+		audio = cache.get(file);
+	} else {
+		audio = new Audio(`/assets/sounds/${file}.mp3`);
+		cache.set(file, audio);
+	}
+	audio.volume = masterVolume - ((1 - volume) * masterVolume);
+	audio.play();
+}
diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts
index c1fc88bf0e..c1580c6367 100644
--- a/src/client/scripts/theme.ts
+++ b/src/client/scripts/theme.ts
@@ -15,19 +15,12 @@ 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/l-white.json5'),
-	require('../themes/l-red.json5'),
-	require('../themes/l-green.json5'),
-	require('../themes/l-blue.json5'),
+	require('../themes/l-light.json5'),
 	require('../themes/l-apricot.json5'),
 
-	require('../themes/d-black.json5'),
-	require('../themes/d-red.json5'),
-	require('../themes/d-green.json5'),
-	require('../themes/d-blue.json5'),
+	require('../themes/d-dark.json5'),
 	require('../themes/d-persimmon.json5'),
-
-	require('../themes/d-battery-saver.json5'),
+	require('../themes/d-black.json5'),
 ] as Theme[];
 
 let timeout = null;
diff --git a/src/client/store.ts b/src/client/store.ts
index cb7f993378..2c63e79503 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -55,7 +55,7 @@ export const defaultDeviceUserSettings = {
 export const defaultDeviceSettings = {
 	lang: null,
 	loadRawImages: false,
-	alwaysShowNsfw: false,
+	nsfw: 'respect', // respect, force, ignore
 	useOsNativeEmojis: false,
 	serverDisconnectedBehavior: 'quiet',
 	accounts: [],
@@ -87,14 +87,6 @@ export const defaultDeviceSettings = {
 	deckColumnAlign: 'left',
 	deckAlwaysShowMainColumn: true,
 	deckMainColumnPlace: 'left',
-	sfxVolume: 0.3,
-	sfxNote: 'syuilo/down',
-	sfxNoteMy: 'syuilo/up',
-	sfxNotification: 'syuilo/pope2',
-	sfxChat: 'syuilo/pope1',
-	sfxChatBg: 'syuilo/waon',
-	sfxAntenna: 'syuilo/triple',
-	sfxChannel: 'syuilo/square-pico',
 	userData: {},
 };
 
diff --git a/src/client/style.scss b/src/client/style.scss
index d7a78dc9c9..85a54706e6 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -448,10 +448,14 @@ hr {
 	opacity: 0.7;
 }
 
+._monospace {
+	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
+}
+
 ._code {
+	@extend ._monospace;
 	background: #2d2d2d;
 	color: #ccc;
-	font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
 	font-size: 14px;
 	line-height: 1.5;
 	padding: 5px;
diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5
index ee6d9b49e9..f290586eb4 100644
--- a/src/client/themes/_dark.json5
+++ b/src/client/themes/_dark.json5
@@ -19,6 +19,7 @@
 		divider: 'rgba(255, 255, 255, 0.1)',
 		indicator: '@accent',
 		panel: '#000',
+		panelHighlight: ':lighten<3<@panel',
 		panelHeaderBg: ':lighten<3<@panel',
 		panelHeaderFg: '@fg',
 		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5
index 8821999395..0a1125cab7 100644
--- a/src/client/themes/_light.json5
+++ b/src/client/themes/_light.json5
@@ -19,6 +19,7 @@
 		divider: 'rgba(0, 0, 0, 0.1)',
 		indicator: '@accent',
 		panel: '#fff',
+		panelHighlight: ':darken<3<@panel',
 		panelHeaderBg: ':lighten<3<@panel',
 		panelHeaderFg: '@fg',
 		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
diff --git a/src/client/themes/d-battery-saver.json5 b/src/client/themes/d-battery-saver.json5
deleted file mode 100644
index e6499ace96..0000000000
--- a/src/client/themes/d-battery-saver.json5
+++ /dev/null
@@ -1,18 +0,0 @@
-{
-	id: '8c539dc1-0fab-4d47-9194-39c508e9bfe1',
-
-	name: 'Battery Saver',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		divider: '#2d2d2d',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		panelShadow: '" 0 0 0 1px var(--divider)',
-		shadow: 'rgba(255, 255, 255, 0.05)',
-		modalBg: 'rgba(255, 255, 255, 0.1)',
-		messageBg: '#1d1d1d',
-	},
-}
diff --git a/src/client/themes/d-black.json5 b/src/client/themes/d-black.json5
index 1e30d56473..b52e0fc394 100644
--- a/src/client/themes/d-black.json5
+++ b/src/client/themes/d-black.json5
@@ -1,29 +1,19 @@
 {
-	id: '8050783a-7f63-445a-b270-36d0f6ba1677',
+	id: '8c539dc1-0fab-4d47-9194-39c508e9bfe1',
 
 	name: 'Mi Black',
 	author: 'syuilo',
-	desc: 'Default light theme',
 
 	base: 'dark',
 
 	props: {
-		bg: '#272727',
-		fg: 'rgb(199, 209, 216)',
-		fgHighlighted: '#fff',
-		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
+		divider: '#2d2d2d',
+		panel: '#0a0a0a',
 		panelHeaderBg: '@panel',
 		panelHeaderDivider: '@divider',
-		infoFg: '@accent',
-		infoBg: 'rgb(0, 0, 0)',
-		header: ':alpha<0.7<@bg',
-		navBg: '#363636',
-		renote: '@accent',
-		mention: '#da6d35',
-		mentionMe: '#d44c4c',
-		hashtag: '#4cb8d4',
-		link: '@accent',
+		panelShadow: '" 0 8px 24px rgb(0 0 0 / 25%)',
+		shadow: 'rgba(255, 255, 255, 0.05)',
+		modalBg: 'rgba(255, 255, 255, 0.1)',
+		messageBg: '#1d1d1d',
 	},
 }
diff --git a/src/client/themes/d-blue.json5 b/src/client/themes/d-blue.json5
deleted file mode 100644
index 96e6240e90..0000000000
--- a/src/client/themes/d-blue.json5
+++ /dev/null
@@ -1,29 +0,0 @@
-{
-	id: 'ab4eb6d5-dcc0-4457-8a3c-98aad8ea3979',
-
-	name: 'Mi D Blue',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: 'rgb(81 185 189)',
-		bg: 'rgb(54, 54, 54)',
-		fg: 'rgb(199, 209, 216)',
-		fgHighlighted: '#fff',
-		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		infoFg: '@accent',
-		infoBg: 'rgb(0, 0, 0)',
-		header: ':alpha<0.7<@bg',
-		navBg: 'rgb(71, 71, 71)',
-		renote: '@accent',
-		mention: '#da6d35',
-		mentionMe: '#d44c4c',
-		hashtag: '#4cb8d4',
-		link: '@accent',
-	},
-}
diff --git a/src/client/themes/d-red.json5 b/src/client/themes/d-dark.json5
similarity index 65%
rename from src/client/themes/d-red.json5
rename to src/client/themes/d-dark.json5
index 0f137322c0..7dd29b4a0f 100644
--- a/src/client/themes/d-red.json5
+++ b/src/client/themes/d-dark.json5
@@ -1,25 +1,25 @@
 {
-	id: '60960086-26da-4f3c-bb0c-f6a4f89e0f60',
+	id: '8050783a-7f63-445a-b270-36d0f6ba1677',
 
-	name: 'Mi D Red',
+	name: 'Mi Dark',
 	author: 'syuilo',
+	desc: 'Default light theme',
 
 	base: 'dark',
 
 	props: {
-		accent: 'rgb(196 115 69)',
-		bg: 'rgb(54, 54, 54)',
+		bg: '#232323',
 		fg: 'rgb(199, 209, 216)',
 		fgHighlighted: '#fff',
 		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
+		panel: '#2d2d2d',
+		panelShadow: '" 0 8px 24px rgb(0 0 0 / 25%)',
 		panelHeaderBg: '@panel',
 		panelHeaderDivider: '@divider',
 		infoFg: '@accent',
 		infoBg: 'rgb(0, 0, 0)',
 		header: ':alpha<0.7<@bg',
-		navBg: 'rgb(71, 71, 71)',
+		navBg: '#363636',
 		renote: '@accent',
 		mention: '#da6d35',
 		mentionMe: '#d44c4c',
diff --git a/src/client/themes/d-green.json5 b/src/client/themes/d-green.json5
deleted file mode 100644
index f1f90d1c78..0000000000
--- a/src/client/themes/d-green.json5
+++ /dev/null
@@ -1,29 +0,0 @@
-{
-	id: '326dc4bf-29d9-45b4-889e-bdc33e84919b',
-
-	name: 'Mi D Green',
-	author: 'syuilo',
-
-	base: 'dark',
-
-	props: {
-		accent: 'rgb(152, 196, 69)',
-		bg: 'rgb(54, 54, 54)',
-		fg: 'rgb(199, 209, 216)',
-		fgHighlighted: '#fff',
-		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
-		panelHeaderBg: '@panel',
-		panelHeaderDivider: '@divider',
-		infoFg: '@accent',
-		infoBg: 'rgb(0, 0, 0)',
-		header: ':alpha<0.7<@bg',
-		navBg: 'rgb(71, 71, 71)',
-		renote: '@accent',
-		mention: '#da6d35',
-		mentionMe: '#d44c4c',
-		hashtag: '#4cb8d4',
-		link: '@accent',
-	},
-}
diff --git a/src/client/themes/d-persimmon.json5 b/src/client/themes/d-persimmon.json5
index 2c32e0797b..067911ace6 100644
--- a/src/client/themes/d-persimmon.json5
+++ b/src/client/themes/d-persimmon.json5
@@ -1,23 +1,23 @@
 {
 	id: 'c503d768-7c70-4db2-a4e6-08264304bc8d',
 
-	name: 'Ai Persimmon',
+	name: 'Mi Persimmon',
 	author: 'syuilo',
 
 	base: 'dark',
 
 	props: {
 		accent: 'rgb(206, 102, 65)',
-		bg: 'rgb(41, 43, 41)',
+		bg: 'rgb(31, 33, 31)',
 		fg: '#cdd8c7',
 		fgHighlighted: '#fff',
 		divider: 'rgba(255, 255, 255, 0.14)',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
+		panel: 'rgb(41, 43, 41)',
+		panelShadow: '" 0 8px 24px rgb(0 0 0 / 25%)',
 		panelHeaderBg: '@panel',
 		panelHeaderDivider: '@divider',
-		infoFg: '@accent',
-		infoBg: 'rgb(0, 0, 0)',
+		infoFg: '@fg',
+		infoBg: '#333c3b',
 		header: ':alpha<0.7<@bg',
 		navBg: '#1f211f',
 		renote: '@accent',
diff --git a/src/client/themes/l-apricot.json5 b/src/client/themes/l-apricot.json5
index 7fbc2b47c7..4bdeea21a5 100644
--- a/src/client/themes/l-apricot.json5
+++ b/src/client/themes/l-apricot.json5
@@ -1,7 +1,7 @@
 {
 	id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b',
 
-	name: 'Ai Apricot',
+	name: 'Mi Apricot',
 	author: 'syuilo',
 
 	base: 'light',
diff --git a/src/client/themes/l-blue.json5 b/src/client/themes/l-blue.json5
deleted file mode 100644
index 06c06da08b..0000000000
--- a/src/client/themes/l-blue.json5
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-	id: 'ad18a23b-6af6-4af0-9ed4-600568250574',
-
-	name: 'Mi L Blue',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: '#4dbccc',
-		bg: '#fff',
-		fg: '#5d5d5d',
-		divider: 'rgb(223, 223, 223)',
-		header: ':alpha<0.7<@bg',
-		navBg: '@bg',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
-		panelHeaderDivider: '@divider',
-		messageBg: '#dedede',
-	},
-}
diff --git a/src/client/themes/l-green.json5 b/src/client/themes/l-green.json5
deleted file mode 100644
index 5a9eb8e0a2..0000000000
--- a/src/client/themes/l-green.json5
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-	id: 'a55af79a-12bf-4f8d-a0cc-718957ad59b4',
-
-	name: 'Mi L Green',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: '#8bcc4d',
-		bg: '#fff',
-		fg: '#5d5d5d',
-		divider: 'rgb(223, 223, 223)',
-		header: ':alpha<0.7<@bg',
-		navBg: '@bg',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
-		panelHeaderDivider: '@divider',
-		messageBg: '#dedede',
-	},
-}
diff --git a/src/client/themes/l-white.json5 b/src/client/themes/l-light.json5
similarity index 95%
rename from src/client/themes/l-white.json5
rename to src/client/themes/l-light.json5
index 9daa60c119..f7ec85d01e 100644
--- a/src/client/themes/l-white.json5
+++ b/src/client/themes/l-light.json5
@@ -1,7 +1,7 @@
 {
 	id: '4eea646f-7afa-4645-83e9-83af0333cd37',
 
-	name: 'Mi White',
+	name: 'Mi Light',
 	author: 'syuilo',
 	desc: 'Default light theme',
 
diff --git a/src/client/themes/l-red.json5 b/src/client/themes/l-red.json5
deleted file mode 100644
index 22139c3aaa..0000000000
--- a/src/client/themes/l-red.json5
+++ /dev/null
@@ -1,21 +0,0 @@
-{
-	id: '957db7cb-30fb-4c80-bf0b-04198e7ae7e3',
-
-	name: 'Mi L Red',
-	author: 'syuilo',
-
-	base: 'light',
-
-	props: {
-		accent: '#fb734d',
-		bg: '#fff',
-		fg: '#5d5d5d',
-		divider: 'rgb(223, 223, 223)',
-		header: ':alpha<0.7<@bg',
-		navBg: '@bg',
-		panel: '@bg',
-		panelShadow: '" 0 0 0 1px var(--divider)',
-		panelHeaderDivider: '@divider',
-		messageBg: '#dedede',
-	},
-}
diff --git a/src/client/ui/_common_/common.vue b/src/client/ui/_common_/common.vue
index d06cbb9869..469220806d 100644
--- a/src/client/ui/_common_/common.vue
+++ b/src/client/ui/_common_/common.vue
@@ -15,8 +15,9 @@
 
 <script lang="ts">
 import { defineAsyncComponent, defineComponent } from 'vue';
-import { stream, sound, popup, popups, uploads, pendingApiRequestsCount } from '@/os';
+import { stream, popup, popups, uploads, pendingApiRequestsCount } from '@/os';
 import { store } from '@/store';
+import * as sound from '@/scripts/sound';
 
 export default defineComponent({
 	components: {
@@ -38,7 +39,7 @@ export default defineComponent({
 				}, {}, 'closed');
 			}
 
-			sound('notification');
+			sound.play('notification');
 		};
 
 		if (store.getters.isSignedIn) {
diff --git a/src/client/ui/visitor.vue b/src/client/ui/visitor.vue
index 56cc270be7..0d83088882 100644
--- a/src/client/ui/visitor.vue
+++ b/src/client/ui/visitor.vue
@@ -1,209 +1,19 @@
 <template>
-<div class="mk-app">
-	<header>
-		<MkA class="link" to="/">{{ $t('home') }}</MkA>
-		<MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA>
-		<MkA class="link" to="/channels">{{ $t('channel') }}</MkA>
-		<MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA>
-	</header>
-
-	<div class="banner" :class="{ asBg: $route.path === '/' }" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }">
-		<h1 v-if="$route.path !== '/'">{{ instanceName }}</h1>
-	</div>
-
-	<div class="contents" ref="contents" :class="{ wallpaper }">
-		<header class="header" ref="header" v-show="$route.path !== '/'">
-			<XHeader :info="pageInfo"/>
-		</header>
-		<main ref="main">
-			<router-view v-slot="{ Component }">
-				<transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-					<component :is="Component" :ref="changePage"/>
-				</transition>
-			</router-view>
-		</main>
-		<div class="powered-by">
-			<b><MkA to="/">{{ host }}</MkA></b>
-			<small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small>
-		</div>
-	</div>
-
-	<XCommon/>
-</div>
+<DesignA/>
+<XCommon/>
 </template>
 
 <script lang="ts">
 import { defineComponent, defineAsyncComponent } from 'vue';
-import { } from '@fortawesome/free-solid-svg-icons';
-import { host, instanceName } from '@/config';
-import { search } from '@/scripts/search';
-import * as os from '@/os';
-import XHeader from './_common_/header.vue';
+import DesignA from './visitor/a.vue';
+import DesignB from './visitor/b.vue';
 import XCommon from './_common_/common.vue';
 
-const DESKTOP_THRESHOLD = 1100;
-
 export default defineComponent({
 	components: {
 		XCommon,
-		XHeader,
+		DesignA,
+		DesignB,
 	},
-
-	data() {
-		return {
-			host,
-			instanceName,
-			pageKey: 0,
-			pageInfo: null,
-			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
-		};
-	},
-
-	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 });
-				},
-				's': search,
-				'h|/': this.help
-			};
-		},
-	},
-
-	watch: {
-		$route(to, from) {
-			this.pageKey++;
-		},
-	},
-
-	created() {
-		document.documentElement.style.overflowY = 'scroll';
-	},
-
-	mounted() {
-		if (!this.isDesktop) {
-			window.addEventListener('resize', () => {
-				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
-			}, { passive: true });
-		}
-	},
-
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page.INFO) {
-				this.pageInfo = page.INFO;
-			}
-		},
-
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
-
-		help() {
-			this.$router.push('/docs/keyboard-shortcut');
-		},
-
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
-	}
 });
 </script>
-
-<style lang="scss" scoped>
-.mk-app {
-	min-height: 100vh;
-
-	> header {
-		position: relative;
-		z-index: 1;
-		background: var(--panel);
-		padding: 0 16px;
-		text-align: center;
-		overflow: auto;
-		white-space: nowrap;
-
-		> .link {
-			display: inline-block;
-			line-height: 60px;
-			padding: 0 0.7em;
-
-			&.MkA-active {
-				box-shadow: 0 -2px 0 0 var(--accent) inset;
-			}
-		}
-	}
-
-	> .banner {
-		position: relative;
-		width: 100%;
-		height: 200px;
-		background-size: cover;
-		background-position: center;
-
-		&.asBg {
-			position: absolute;
-			left: 0;
-			height: 320px;
-		}
-
-		&:after {
-			content: "";
-			display: block;
-			position: absolute;
-			bottom: 0;
-			left: 0;
-			width: 100%;
-			height: 64px;
-			background: linear-gradient(transparent, var(--bg));
-		}
-
-		> h1 {
-			margin: 0;
-			text-align: center;
-			color: #fff;
-			text-shadow: 0 0 8px #000;
-			line-height: 200px;
-		}
-	}
-
-	> .contents {
-		position: relative;
-		z-index: 1;
-
-		> .header {
-			position: sticky;
-			top: 0;
-			left: 0;
-			z-index: 1000;
-			height: 60px;
-			width: 100%;
-			line-height: 60px;
-			text-align: center;
-			-webkit-backdrop-filter: blur(32px);
-			backdrop-filter: blur(32px);
-			background-color: var(--header);
-			border-bottom: 1px solid var(--divider);
-		}
-
-		> .powered-by {
-			padding: 28px;
-			font-size: 14px;
-			text-align: center;
-			border-top: 1px solid var(--divider);
-
-			> small {
-				display: block;
-				margin-top: 8px;
-				opacity: 0.5;
-			}
-		}
-	}
-}
-</style>
-
-<style lang="scss">
-</style>
diff --git a/src/client/ui/visitor/a.vue b/src/client/ui/visitor/a.vue
new file mode 100644
index 0000000000..da09a9363b
--- /dev/null
+++ b/src/client/ui/visitor/a.vue
@@ -0,0 +1,357 @@
+<template>
+<div class="mk-app">
+	<div class="banner" v-if="$route.path === '/'" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }">
+		<div>
+			<header>
+				<MkA class="link" to="/">{{ $t('home') }}</MkA>
+				<MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA>
+				<MkA class="link" to="/channels">{{ $t('channel') }}</MkA>
+				<MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA>
+			</header>
+			<h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+			<div class="about" v-if="meta">
+				<div class="desc" v-html="meta.description || $t('introMisskey')"></div>
+			</div>
+			<div class="action">
+				<button class="_button primary" @click="signup()">{{ $t('signup') }}</button>
+				<button class="_button" @click="signin()">{{ $t('login') }}</button>
+			</div>
+		</div>
+	</div>
+	<div class="banner-mini" v-else :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }">
+		<div>
+			<header>
+				<MkA class="link" to="/">{{ $t('home') }}</MkA>
+				<MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA>
+				<MkA class="link" to="/channels">{{ $t('channel') }}</MkA>
+				<MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA>
+				<div class="action">
+					<button class="_button primary" @click="signup()">{{ $t('signup') }}</button>
+					<button class="_button" @click="signin()">{{ $t('login') }}</button>
+				</div>
+			</header>
+			<h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+		</div>
+	</div>
+
+	<div class="main">
+		<div class="contents" ref="contents" :class="{ wallpaper }">
+			<header class="header" ref="header" v-show="$route.path !== '/'">
+				<XHeader :info="pageInfo"/>
+			</header>
+			<main ref="main">
+				<router-view v-slot="{ Component }">
+					<transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+						<component :is="Component" :ref="changePage"/>
+					</transition>
+				</router-view>
+			</main>
+			<div class="powered-by">
+				<b><MkA to="/">{{ host }}</MkA></b>
+				<small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { } from '@fortawesome/free-solid-svg-icons';
+import { host, instanceName } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import MkPagination from '@/components/ui/pagination.vue';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XHeader from '../_common_/header.vue';
+
+const DESKTOP_THRESHOLD = 1100;
+
+export default defineComponent({
+	components: {
+		XHeader,
+		MkPagination,
+		MkButton,
+	},
+
+	data() {
+		return {
+			host,
+			instanceName,
+			pageKey: 0,
+			pageInfo: null,
+			meta: null,
+			narrow: window.innerWidth < 1280,
+			announcements: {
+				endpoint: 'announcements',
+				limit: 10,
+			},
+			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
+		};
+	},
+
+	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 });
+				},
+				's': search,
+				'h|/': this.help
+			};
+		},
+	},
+
+	watch: {
+		$route(to, from) {
+			this.pageKey++;
+		},
+	},
+
+	created() {
+		document.documentElement.style.overflowY = 'scroll';
+
+		os.api('meta', { detail: true }).then(meta => {
+			this.meta = meta;
+		});
+	},
+
+	mounted() {
+		if (!this.isDesktop) {
+			window.addEventListener('resize', () => {
+				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
+			}, { passive: true });
+		}
+	},
+
+	methods: {
+		setParallax(el) {
+			//new simpleParallax(el);
+		},
+
+		changePage(page) {
+			if (page == null) return;
+			if (page.INFO) {
+				this.pageInfo = page.INFO;
+			}
+		},
+
+		top() {
+			window.scroll({ top: 0, behavior: 'smooth' });
+		},
+
+		help() {
+			this.$router.push('/docs/keyboard-shortcut');
+		},
+
+		onTransition() {
+			if (window._scroll) window._scroll();
+		},
+
+		signin() {
+			os.popup(XSigninDialog, {
+				autoSet: true
+			}, {}, 'closed');
+		},
+
+		signup() {
+			os.popup(XSignupDialog, {
+				autoSet: true
+			}, {}, 'closed');
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+	min-height: 100vh;
+
+	> .banner {
+		position: relative;
+		width: 100%;
+		text-align: center;
+		background-position: center;
+		background-size: cover;
+
+		> div {
+			height: 100%;
+			background: rgba(0, 0, 0, 0.3);
+
+			* {
+				color: #fff;
+			}
+					
+			> h1 {
+				margin: 0;
+				padding: 96px 32px 0 32px;
+				text-shadow: 0 0 8px black;
+
+				> .logo {
+					vertical-align: bottom;
+					max-height: 150px;
+				}
+			}
+
+			> .about {
+				padding: 32px;
+				max-width: 580px;
+				margin: 0 auto;
+				box-sizing: border-box;
+				text-shadow: 0 0 8px black;
+			}
+
+			> .action {
+				padding-bottom: 64px;
+				
+				> button {
+					display: inline-block;
+					padding: 10px 20px;
+					box-sizing: border-box;
+					text-align: center;
+					border-radius: 999px;
+					background: var(--panel);
+					color: var(--fg);
+
+					&.primary {
+						background: var(--accent);
+						color: #fff;
+					}
+
+					&:first-child {
+						margin-right: 16px;
+					}
+				}
+			}
+		}
+	}
+
+	> .banner-mini {
+		position: relative;
+		width: 100%;
+		text-align: center;
+		background-position: center;
+		background-size: cover;
+
+		> div {
+			position: relative;
+			z-index: 1;
+			height: 100%;
+			background: rgba(0, 0, 0, 0.3);
+
+			* {
+				color: #fff !important;
+			}
+
+			> header {
+				
+			}
+					
+			> h1 {
+				margin: 0;
+				padding: 32px;
+				text-shadow: 0 0 8px black;
+
+				> .logo {
+					vertical-align: bottom;
+					max-height: 100px;
+				}
+			}
+		}
+	}
+
+	> .main {
+		> header {
+			position: relative;
+			z-index: 1;
+			background: var(--panel);
+			padding: 0 32px;
+			text-align: left;
+			overflow: auto;
+			white-space: nowrap;
+
+			> .link {
+				display: inline-block;
+				line-height: 60px;
+				padding: 0 0.7em;
+
+				&.MkA-active {
+					box-shadow: 0 -2px 0 0 var(--accent) inset;
+				}
+			}
+		}
+
+		> .banner {
+			position: relative;
+			width: 100%;
+			height: 200px;
+			background-size: cover;
+			background-position: center;
+
+			&.asBg {
+				position: absolute;
+				left: 0;
+				height: 320px;
+			}
+
+			&:after {
+				content: "";
+				display: block;
+				position: absolute;
+				bottom: 0;
+				left: 0;
+				width: 100%;
+				height: 64px;
+				background: linear-gradient(transparent, var(--bg));
+			}
+
+			> h1 {
+				margin: 0;
+				text-align: center;
+				color: #fff;
+				text-shadow: 0 0 8px #000;
+				line-height: 200px;
+			}
+		}
+
+		> .contents {
+			position: relative;
+			z-index: 1;
+
+			> .header {
+				position: sticky;
+				top: 0;
+				left: 0;
+				z-index: 1000;
+				height: 60px;
+				width: 100%;
+				line-height: 60px;
+				text-align: center;
+				-webkit-backdrop-filter: blur(32px);
+				backdrop-filter: blur(32px);
+				background-color: var(--header);
+				border-bottom: 1px solid var(--divider);
+			}
+
+			> .powered-by {
+				padding: 28px;
+				font-size: 14px;
+				text-align: center;
+				border-top: 1px solid var(--divider);
+
+				> small {
+					display: block;
+					margin-top: 8px;
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss">
+</style>
diff --git a/src/client/ui/visitor/b.vue b/src/client/ui/visitor/b.vue
new file mode 100644
index 0000000000..13f93a522e
--- /dev/null
+++ b/src/client/ui/visitor/b.vue
@@ -0,0 +1,372 @@
+<template>
+<div class="mk-app">
+	<div class="side" v-if="!narrow">
+		<div :style="{ backgroundImage: `url(${ $store.state.instance.meta.backgroundImageUrl })` }">
+			<div class="fade"></div>
+			<h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+			<div class="about _panel" v-if="meta">
+				<div class="desc" v-html="meta.description || $t('introMisskey')"></div>
+			</div>
+			<div class="action">
+				<button class="_button primary" @click="signup()">{{ $t('signup') }}</button>
+				<button class="_button" @click="signin()">{{ $t('login') }}</button>
+			</div>
+			<div class="announcements panel">
+				<header>{{ $t('announcements') }}</header>
+				<MkPagination :pagination="announcements" #default="{items}" class="list">
+					<section class="item" v-for="(announcement, i) in items" :key="announcement.id">
+						<div class="title">{{ announcement.title }}</div>
+						<div class="content">
+							<Mfm :text="announcement.text"/>
+							<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+						</div>
+					</section>
+				</MkPagination>
+			</div>
+		</div>
+	</div>
+
+	<div class="main">
+		<header>
+			<MkA class="link" to="/">{{ $t('home') }}</MkA>
+			<MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA>
+			<MkA class="link" to="/channels">{{ $t('channel') }}</MkA>
+			<MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA>
+		</header>
+
+		<div v-if="narrow" class="banner" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }">
+			<h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+		</div>
+
+		<div class="contents" ref="contents" :class="{ wallpaper }">
+			<header class="header" ref="header" v-show="$route.path !== '/'">
+				<XHeader :info="pageInfo"/>
+			</header>
+			<main ref="main">
+				<router-view v-slot="{ Component }">
+					<transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+						<component :is="Component" :ref="changePage"/>
+					</transition>
+				</router-view>
+			</main>
+			<div class="powered-by">
+				<b><MkA to="/">{{ host }}</MkA></b>
+				<small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { } from '@fortawesome/free-solid-svg-icons';
+import { host, instanceName } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import MkPagination from '@/components/ui/pagination.vue';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XHeader from '../_common_/header.vue';
+
+const DESKTOP_THRESHOLD = 1100;
+
+export default defineComponent({
+	components: {
+		XHeader,
+		MkPagination,
+		MkButton,
+	},
+
+	data() {
+		return {
+			host,
+			instanceName,
+			pageKey: 0,
+			pageInfo: null,
+			meta: null,
+			narrow: window.innerWidth < 1280,
+			announcements: {
+				endpoint: 'announcements',
+				limit: 10,
+			},
+			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
+		};
+	},
+
+	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 });
+				},
+				's': search,
+				'h|/': this.help
+			};
+		},
+	},
+
+	watch: {
+		$route(to, from) {
+			this.pageKey++;
+		},
+	},
+
+	created() {
+		document.documentElement.style.overflowY = 'scroll';
+
+		os.api('meta', { detail: true }).then(meta => {
+			this.meta = meta;
+		});
+	},
+
+	mounted() {
+		if (!this.isDesktop) {
+			window.addEventListener('resize', () => {
+				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
+			}, { passive: true });
+		}
+	},
+
+	methods: {
+		changePage(page) {
+			if (page == null) return;
+			if (page.INFO) {
+				this.pageInfo = page.INFO;
+			}
+		},
+
+		top() {
+			window.scroll({ top: 0, behavior: 'smooth' });
+		},
+
+		help() {
+			this.$router.push('/docs/keyboard-shortcut');
+		},
+
+		onTransition() {
+			if (window._scroll) window._scroll();
+		},
+
+		signin() {
+			os.popup(XSigninDialog, {
+				autoSet: true
+			}, {}, 'closed');
+		},
+
+		signup() {
+			os.popup(XSignupDialog, {
+				autoSet: true
+			}, {}, 'closed');
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+	display: flex;
+	min-height: 100vh;
+
+	> .side {
+		width: 500px;
+		height: 100vh;
+		text-align: center;
+
+		> div {
+			position: fixed;
+			top: 0;
+			left: 0;
+			width: 500px;
+			height: 100vh;
+			background-position: center;
+			background-size: cover;
+
+			> .panel {
+				-webkit-backdrop-filter: blur(8px);
+				backdrop-filter: blur(8px);
+				background: rgba(0, 0, 0, 0.5);
+				border-radius: var(--radius);
+
+				&, * {
+					color: #fff !important;
+				}
+			}
+
+			> .fade {
+				position: absolute;
+				z-index: -1;
+				top: 0;
+				left: 0;
+				width: 100%;
+				height: 300px;
+				background: linear-gradient(rgba(#000, 0.5), transparent);
+			}
+
+			> h1 {
+				display: block;
+				margin: 0;
+				padding: 64px 32px 48px 32px;
+				color: #fff;
+
+				> .logo {
+					vertical-align: bottom;
+					max-height: 150px;
+				}
+			}
+
+			> .about {
+				display: block;
+				margin: 0 64px 16px 64px;
+				padding: 24px;
+				text-align: center;
+				box-sizing: border-box;
+			}
+
+			> .action {
+				padding: 0 64px;
+
+				> button {
+					display: block;
+					width: 100%;
+					padding: 10px;
+					box-sizing: border-box;
+					text-align: center;
+					border-radius: 999px;
+					background: var(--panel);
+
+					&.primary {
+						background: var(--accent);
+						color: #fff;
+					}
+
+					&:first-child {
+						margin-bottom: 16px;
+					}
+				}
+			}
+
+			> .announcements {
+				margin: 64px 64px 16px 64px;
+				text-align: left;
+
+				> header {
+					padding: 12px 16px;
+					border-bottom: solid 1px rgba(255, 255, 255, 0.5);
+				}
+
+				> .list {
+					max-height: 300px;
+					overflow: auto;
+
+					> .item {
+						padding: 12px 16px;
+
+						& + .item {
+							border-top: solid 1px rgba(255, 255, 255, 0.5);
+						}
+
+						> .title {
+							font-weight: bold;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	> .main {
+		flex: 1;
+
+		> header {
+			position: relative;
+			z-index: 1;
+			background: var(--panel);
+			padding: 0 32px;
+			text-align: left;
+			overflow: auto;
+			white-space: nowrap;
+
+			> .link {
+				display: inline-block;
+				line-height: 60px;
+				padding: 0 0.7em;
+
+				&.MkA-active {
+					box-shadow: 0 -2px 0 0 var(--accent) inset;
+				}
+			}
+		}
+
+		> .banner {
+			position: relative;
+			width: 100%;
+			height: 200px;
+			background-size: cover;
+			background-position: center;
+
+			&:after {
+				content: "";
+				display: block;
+				position: absolute;
+				bottom: 0;
+				left: 0;
+				width: 100%;
+				height: 64px;
+				background: linear-gradient(transparent, var(--bg));
+			}
+
+			> h1 {
+				margin: 0;
+				padding: 32px;
+				text-align: center;
+				color: #fff;
+				text-shadow: 0 0 8px #000;
+
+				> .logo {
+					vertical-align: bottom;
+					max-height: 150px;
+				}
+			}
+		}
+
+		> .contents {
+			position: relative;
+			z-index: 1;
+
+			> .header {
+				position: sticky;
+				top: 0;
+				left: 0;
+				z-index: 1000;
+				height: 60px;
+				width: 100%;
+				line-height: 60px;
+				text-align: center;
+				-webkit-backdrop-filter: blur(32px);
+				backdrop-filter: blur(32px);
+				background-color: var(--header);
+				border-bottom: 1px solid var(--divider);
+			}
+
+			> .powered-by {
+				padding: 28px;
+				font-size: 14px;
+				text-align: center;
+				border-top: 1px solid var(--divider);
+
+				> small {
+					display: block;
+					margin-top: 8px;
+					opacity: 0.5;
+				}
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss">
+</style>
diff --git a/src/client/widgets/digital-clock.vue b/src/client/widgets/digital-clock.vue
index 702f335c7f..9d32e8b9fe 100644
--- a/src/client/widgets/digital-clock.vue
+++ b/src/client/widgets/digital-clock.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="mkw-digitalClock" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }">
+<div class="mkw-digitalClock _monospace" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }">
 	<span>
 		<span v-text="hh"></span>
 		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
@@ -74,7 +74,6 @@ export default defineComponent({
 <style lang="scss" scoped>
 .mkw-digitalClock {
 	padding: 16px 0;
-	font-family: Lucida Console, Courier, monospace;
 	text-align: center;
 }
 </style>
diff --git a/src/games/reversi/maps.ts b/src/games/reversi/maps.ts
index b95eb4f02d..dc0d1bf9d0 100644
--- a/src/games/reversi/maps.ts
+++ b/src/games/reversi/maps.ts
@@ -878,3 +878,19 @@ export const test7: Map = {
 		'--wwww--',
 	]
 };
+
+// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう
+export const test8: Map = {
+	name: 'Test8',
+	category: 'Test',
+	data: [
+		'--------',
+		'-----w--',
+		'w--www--',
+		'wwwwww--',
+		'bbbbwww-',
+		'wwwwww--',
+		'--www---',
+		'--ww----',
+	]
+};
diff --git a/src/models/entities/meta.ts b/src/models/entities/meta.ts
index b7fe8b18ad..cfc9782614 100644
--- a/src/models/entities/meta.ts
+++ b/src/models/entities/meta.ts
@@ -77,7 +77,7 @@ export class Meta {
 	public blockedHosts: string[];
 
 	@Column('varchar', {
-		length: 512, array: true, default: '{"/announcements", "/featured", "/channels", "/explore", "/games/reversi", "/about-misskey"}'
+		length: 512, array: true, default: '{"/featured", "/channels", "/explore", "/pages", "/about-misskey"}'
 	})
 	public pinnedPages: string[];
 
@@ -94,6 +94,18 @@ export class Meta {
 	})
 	public bannerUrl: string | null;
 
+	@Column('varchar', {
+		length: 512,
+		nullable: true
+	})
+	public backgroundImageUrl: string | null;
+
+	@Column('varchar', {
+		length: 512,
+		nullable: true
+	})
+	public logoImageUrl: string | null;
+
 	@Column('varchar', {
 		length: 512,
 		nullable: true,
diff --git a/src/models/entities/note-reaction.ts b/src/models/entities/note-reaction.ts
index ed38450bb2..69bb663fd3 100644
--- a/src/models/entities/note-reaction.ts
+++ b/src/models/entities/note-reaction.ts
@@ -35,6 +35,8 @@ export class NoteReaction {
 	@JoinColumn()
 	public note: Note | null;
 
+	// TODO: 対象noteのuserIdを非正規化したい(「受け取ったリアクション一覧」のようなものを(JOIN無しで)実装したいため)
+
 	@Column('varchar', {
 		length: 260
 	})
diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts
index bd37da5ecc..97a4150be0 100644
--- a/src/models/entities/user-profile.ts
+++ b/src/models/entities/user-profile.ts
@@ -111,6 +111,12 @@ export class UserProfile {
 	})
 	public autoAcceptFollowed: boolean;
 
+	@Column('boolean', {
+		default: false,
+		comment: 'Whether reject index by crawler.'
+	})
+	public noCrawle: boolean;
+
 	@Column('boolean', {
 		default: false,
 	})
diff --git a/src/models/repositories/drive-file.ts b/src/models/repositories/drive-file.ts
index 7d1f2b9fec..ab22d2dc09 100644
--- a/src/models/repositories/drive-file.ts
+++ b/src/models/repositories/drive-file.ts
@@ -48,7 +48,7 @@ export class DriveFileRepository extends Repository<DriveFile> {
 		return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url);
 	}
 
-	public async clacDriveUsageOf(user: User['id'] | User): Promise<number> {
+	public async calcDriveUsageOf(user: User['id'] | User): Promise<number> {
 		const id = typeof user === 'object' ? user.id : user;
 
 		const { sum } = await this
@@ -60,7 +60,7 @@ export class DriveFileRepository extends Repository<DriveFile> {
 		return parseInt(sum, 10) || 0;
 	}
 
-	public async clacDriveUsageOfHost(host: string): Promise<number> {
+	public async calcDriveUsageOfHost(host: string): Promise<number> {
 		const { sum } = await this
 			.createQueryBuilder('file')
 			.where('file.userHost = :host', { host: toPuny(host) })
@@ -70,7 +70,7 @@ export class DriveFileRepository extends Repository<DriveFile> {
 		return parseInt(sum, 10) || 0;
 	}
 
-	public async clacDriveUsageOfLocal(): Promise<number> {
+	public async calcDriveUsageOfLocal(): Promise<number> {
 		const { sum } = await this
 			.createQueryBuilder('file')
 			.where('file.userHost IS NULL')
@@ -80,7 +80,7 @@ export class DriveFileRepository extends Repository<DriveFile> {
 		return parseInt(sum, 10) || 0;
 	}
 
-	public async clacDriveUsageOfRemote(): Promise<number> {
+	public async calcDriveUsageOfRemote(): Promise<number> {
 		const { sum } = await this
 			.createQueryBuilder('file')
 			.where('file.userHost IS NOT NULL')
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index a334196832..87f50b448b 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -239,6 +239,7 @@ export class UserRepository extends Repository<User> {
 				alwaysMarkNsfw: profile!.alwaysMarkNsfw,
 				carefulBot: profile!.carefulBot,
 				autoAcceptFollowed: profile!.autoAcceptFollowed,
+				noCrawle: profile!.noCrawle,
 				hasUnreadSpecifiedNotes: NoteUnreads.count({
 					where: { userId: user.id, isSpecified: true },
 					take: 1
diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts
index ae6d2a4163..acb29b9e51 100644
--- a/src/server/api/endpoints/admin/update-meta.ts
+++ b/src/server/api/endpoints/admin/update-meta.ts
@@ -94,6 +94,14 @@ export const meta = {
 			}
 		},
 
+		backgroundImageUrl: {
+			validator: $.optional.nullable.str,
+		},
+
+		logoImageUrl: {
+			validator: $.optional.nullable.str,
+		},
+
 		name: {
 			validator: $.optional.nullable.str,
 			desc: {
@@ -473,6 +481,14 @@ export default define(meta, async (ps, me) => {
 		set.iconUrl = ps.iconUrl;
 	}
 
+	if (ps.backgroundImageUrl !== undefined) {
+		set.backgroundImageUrl = ps.backgroundImageUrl;
+	}
+
+	if (ps.logoImageUrl !== undefined) {
+		set.logoImageUrl = ps.logoImageUrl;
+	}
+
 	if (ps.name !== undefined) {
 		set.name = ps.name;
 	}
diff --git a/src/server/api/endpoints/drive.ts b/src/server/api/endpoints/drive.ts
index 9b723a0542..527b7719a4 100644
--- a/src/server/api/endpoints/drive.ts
+++ b/src/server/api/endpoints/drive.ts
@@ -34,7 +34,7 @@ export default define(meta, async (ps, user) => {
 	const instance = await fetchMeta(true);
 
 	// Calculate drive usage
-	const usage = await DriveFiles.clacDriveUsageOf(user);
+	const usage = await DriveFiles.calcDriveUsageOf(user);
 
 	return {
 		capacity: 1024 * 1024 * instance.localDriveCapacityMb,
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index d3a8e0a8ce..0872671208 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -106,6 +106,13 @@ export const meta = {
 			}
 		},
 
+		noCrawle: {
+			validator: $.optional.bool,
+			desc: {
+				'ja-JP': '検索エンジンによるインデックスを拒否するか否か'
+			}
+		},
+
 		isBot: {
 			validator: $.optional.bool,
 			desc: {
@@ -204,6 +211,7 @@ export default define(meta, async (ps, user, token) => {
 	if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
 	if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
 	if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
+	if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle;
 	if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat;
 	if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
 	if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts
index 97376a9d73..f24493899a 100644
--- a/src/server/api/endpoints/meta.ts
+++ b/src/server/api/endpoints/meta.ts
@@ -129,6 +129,8 @@ export default define(meta, async (ps, me) => {
 		bannerUrl: instance.bannerUrl,
 		errorImageUrl: instance.errorImageUrl,
 		iconUrl: instance.iconUrl,
+		backgroundImageUrl: instance.backgroundImageUrl,
+		logoImageUrl: instance.logoImageUrl,
 		maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH),
 		emojis: await Emojis.packMany(emojis),
 		enableEmail: instance.enableEmail,
diff --git a/src/server/api/endpoints/users/stats.ts b/src/server/api/endpoints/users/stats.ts
new file mode 100644
index 0000000000..50730e7cd0
--- /dev/null
+++ b/src/server/api/endpoints/users/stats.ts
@@ -0,0 +1,144 @@
+import $ from 'cafy';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { ID } from '../../../../misc/cafy-id';
+import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, ReversiGames, Users } from '../../../../models';
+
+export const meta = {
+	tags: ['users'],
+
+	requireCredential: false as const,
+
+	params: {
+		userId: {
+			validator: $.type(ID),
+		},
+	},
+
+	errors: {
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '9e638e45-3b25-4ef7-8f95-07e8498f1819'
+		},
+	}
+};
+
+export default define(meta, async (ps, me) => {
+	const user = await Users.findOne(ps.userId);
+	if (user == null) {
+		throw new ApiError(meta.errors.noSuchUser);
+	}
+
+	const [
+		notesCount,
+		repliesCount,
+		renotesCount,
+		repliedCount,
+		renotedCount,
+		pollVotesCount,
+		pollVotedCount,
+		localFollowingCount,
+		remoteFollowingCount,
+		localFollowersCount,
+		remoteFollowersCount,
+		sentReactionsCount,
+		receivedReactionsCount,
+		noteFavoritesCount,
+		pageLikesCount,
+		pageLikedCount,
+		driveFilesCount,
+		driveUsage,
+		reversiCount,
+	] = await Promise.all([
+		Notes.createQueryBuilder('note')
+			.where('note.userId = :userId', { userId: user.id })
+			.getCount(),
+		Notes.createQueryBuilder('note')
+			.where('note.userId = :userId', { userId: user.id })
+			.andWhere('note.replyId IS NOT NULL')
+			.getCount(),
+		Notes.createQueryBuilder('note')
+			.where('note.userId = :userId', { userId: user.id })
+			.andWhere('note.renoteId IS NOT NULL')
+			.getCount(),
+		Notes.createQueryBuilder('note')
+			.where('note.replyUserId = :userId', { userId: user.id })
+			.getCount(),
+		Notes.createQueryBuilder('note')
+			.where('note.renoteUserId = :userId', { userId: user.id })
+			.getCount(),
+		PollVotes.createQueryBuilder('vote')
+			.where('vote.userId = :userId', { userId: user.id })
+			.getCount(),
+		PollVotes.createQueryBuilder('vote')
+			.innerJoin('vote.note', 'note')
+			.where('note.userId = :userId', { userId: user.id })
+			.getCount(),
+		Followings.createQueryBuilder('following')
+			.where('following.followerId = :userId', { userId: user.id })
+			.andWhere('following.followeeHost IS NULL')
+			.getCount(),
+		Followings.createQueryBuilder('following')
+			.where('following.followerId = :userId', { userId: user.id })
+			.andWhere('following.followeeHost IS NOT NULL')
+			.getCount(),
+		Followings.createQueryBuilder('following')
+			.where('following.followeeId = :userId', { userId: user.id })
+			.andWhere('following.followerHost IS NULL')
+			.getCount(),
+		Followings.createQueryBuilder('following')
+			.where('following.followeeId = :userId', { userId: user.id })
+			.andWhere('following.followerHost IS NOT NULL')
+			.getCount(),
+		NoteReactions.createQueryBuilder('reaction')
+			.where('reaction.userId = :userId', { userId: user.id })
+			.getCount(),
+		NoteReactions.createQueryBuilder('reaction')
+			.innerJoin('reaction.note', 'note')
+			.where('note.userId = :userId', { userId: user.id })
+			.getCount(),
+		NoteFavorites.createQueryBuilder('favorite')
+			.where('favorite.userId = :userId', { userId: user.id })
+			.getCount(),
+		PageLikes.createQueryBuilder('like')
+			.where('like.userId = :userId', { userId: user.id })
+			.getCount(),
+		PageLikes.createQueryBuilder('like')
+			.innerJoin('like.page', 'page')
+			.where('page.userId = :userId', { userId: user.id })
+			.getCount(),
+		DriveFiles.createQueryBuilder('file')
+			.where('file.userId = :userId', { userId: user.id })
+			.getCount(),
+		DriveFiles.calcDriveUsageOf(user),
+		ReversiGames.createQueryBuilder('game')
+			.where('game.user1Id = :userId', { userId: user.id })
+			.orWhere('game.user2Id = :userId', { userId: user.id })
+			.getCount(),
+	]);
+
+	return {
+		notesCount,
+		repliesCount,
+		renotesCount,
+		repliedCount,
+		renotedCount,
+		pollVotesCount,
+		pollVotedCount,
+		localFollowingCount,
+		remoteFollowingCount,
+		localFollowersCount,
+		remoteFollowersCount,
+		followingCount: localFollowingCount + remoteFollowingCount,
+		followersCount: localFollowersCount + remoteFollowersCount,
+		sentReactionsCount,
+		receivedReactionsCount,
+		noteFavoritesCount,
+		pageLikesCount,
+		pageLikedCount,
+		driveFilesCount,
+		driveUsage,
+		reversiCount,
+	};
+});
diff --git a/src/server/index.ts b/src/server/index.ts
index 15e1fedc98..5a7bb99c63 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -21,10 +21,11 @@ import apiServer from './api';
 import { sum } from '../prelude/array';
 import Logger from '../services/logger';
 import { program } from '../argv';
-import { UserProfiles } from '../models';
+import { UserProfiles, Users } from '../models';
 import { networkChart } from '../services/chart';
 import { genAvatar } from '../misc/gen-avatar';
 import { createTemp } from '../misc/create-temp';
+import { publishMainStream } from '../services/stream';
 
 export const serverLogger = new Logger('server', 'gray', false);
 
@@ -83,10 +84,15 @@ router.get('/verify-email/:code', async ctx => {
 		ctx.body = 'Verify succeeded!';
 		ctx.status = 200;
 
-		UserProfiles.update({ userId: profile.userId }, {
+		await UserProfiles.update({ userId: profile.userId }, {
 			emailVerified: true,
 			emailVerifyCode: null
 		});
+
+		publishMainStream(profile.userId, 'meUpdated', await Users.pack(profile.userId, profile.userId, {
+			detail: true,
+			includeSecrets: true
+		}));
 	} else {
 		ctx.status = 404;
 	}
diff --git a/src/server/web/index.ts b/src/server/web/index.ts
index 0bc9f242ad..468ece5359 100644
--- a/src/server/web/index.ts
+++ b/src/server/web/index.ts
@@ -242,9 +242,11 @@ router.get('/notes/:note', async ctx => {
 
 	if (note) {
 		const _note = await Notes.pack(note);
+		const profile = await UserProfiles.findOne(note.userId).then(ensure);
 		const meta = await fetchMeta();
 		await ctx.render('note', {
 			note: _note,
+			profile,
 			// TODO: Let locale changeable by instance setting
 			summary: getNoteSummary(_note, locales['ja-JP']),
 			instanceName: meta.name || 'Misskey',
@@ -280,9 +282,11 @@ router.get('/@:user/pages/:page', async ctx => {
 
 	if (page) {
 		const _page = await Pages.pack(page);
+		const profile = await UserProfiles.findOne(page.userId).then(ensure);
 		const meta = await fetchMeta();
 		await ctx.render('page', {
 			page: _page,
+			profile,
 			instanceName: meta.name || 'Misskey'
 		});
 
@@ -307,9 +311,11 @@ router.get('/clips/:clip', async ctx => {
 
 	if (clip) {
 		const _clip = await Clips.pack(clip);
+		const profile = await UserProfiles.findOne(clip.userId).then(ensure);
 		const meta = await fetchMeta();
 		await ctx.render('clip', {
 			clip: _clip,
+			profile,
 			instanceName: meta.name || 'Misskey'
 		});
 
diff --git a/src/server/web/views/clip.pug b/src/server/web/views/clip.pug
index 8cd1c673ed..8de53f19d6 100644
--- a/src/server/web/views/clip.pug
+++ b/src/server/web/views/clip.pug
@@ -19,6 +19,9 @@ block og
 	meta(property='og:image'       content= user.avatarUrl)
 
 block meta
+	if profile.noCrawle
+		meta(name='robots' content='noindex')
+
 	meta(name='misskey:user-username' content=user.username)
 	meta(name='misskey:user-id' content=user.id)
 	meta(name='misskey:clip-id' content=clip.id)
diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug
index 0580e959f7..7030936975 100644
--- a/src/server/web/views/note.pug
+++ b/src/server/web/views/note.pug
@@ -19,6 +19,9 @@ block og
 	meta(property='og:image'       content= user.avatarUrl)
 
 block meta
+	if user.host || profile.noCrawle
+		meta(name='robots' content='noindex')
+
 	meta(name='misskey:user-username' content=user.username)
 	meta(name='misskey:user-id' content=user.id)
 	meta(name='misskey:note-id' content=note.id)
@@ -26,9 +29,6 @@ block meta
 	meta(name='twitter:card' content='summary')
 
 	// todo
-	if user.host
-		meta(name='robots' content='noindex')
-
 	if user.twitter
 		meta(name='twitter:creator' content=`@${user.twitter.screenName}`)
 
diff --git a/src/server/web/views/page.pug b/src/server/web/views/page.pug
index 55f64ff054..cb9e1039e1 100644
--- a/src/server/web/views/page.pug
+++ b/src/server/web/views/page.pug
@@ -19,6 +19,9 @@ block og
 	meta(property='og:image'       content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : user.avatarUrl)
 
 block meta
+	if profile.noCrawle
+		meta(name='robots' content='noindex')
+
 	meta(name='misskey:user-username' content=user.username)
 	meta(name='misskey:user-id' content=user.id)
 	meta(name='misskey:page-id' content=page.id)
diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug
index d41b0bbac0..1a8a6b4413 100644
--- a/src/server/web/views/user.pug
+++ b/src/server/web/views/user.pug
@@ -19,14 +19,14 @@ block og
 	meta(property='og:image'       content= img)
 
 block meta
+	if user.host || profile.noCrawle
+		meta(name='robots' content='noindex')
+
 	meta(name='misskey:user-username' content=user.username)
 	meta(name='misskey:user-id' content=user.id)
 
 	meta(name='twitter:card' content='summary')
 
-	if user.host
-		meta(name='robots' content='noindex')
-
 	if profile.twitter
 		meta(name='twitter:creator' content=`@${profile.twitter.screenName}`)
 
diff --git a/src/services/chart/charts/classes/drive.ts b/src/services/chart/charts/classes/drive.ts
index c3bcacb7df..57bb120beb 100644
--- a/src/services/chart/charts/classes/drive.ts
+++ b/src/services/chart/charts/classes/drive.ts
@@ -32,8 +32,8 @@ export default class DriveChart extends Chart<DriveLog> {
 		const [localCount, remoteCount, localSize, remoteSize] = await Promise.all([
 			DriveFiles.count({ userHost: null }),
 			DriveFiles.count({ userHost: Not(IsNull()) }),
-			DriveFiles.clacDriveUsageOfLocal(),
-			DriveFiles.clacDriveUsageOfRemote()
+			DriveFiles.calcDriveUsageOfLocal(),
+			DriveFiles.calcDriveUsageOfRemote()
 		]);
 
 		return {
diff --git a/src/services/chart/charts/classes/instance.ts b/src/services/chart/charts/classes/instance.ts
index f3d341f383..7575abfb6f 100644
--- a/src/services/chart/charts/classes/instance.ts
+++ b/src/services/chart/charts/classes/instance.ts
@@ -51,7 +51,7 @@ export default class InstanceChart extends Chart<InstanceLog> {
 			Followings.count({ followerHost: group }),
 			Followings.count({ followeeHost: group }),
 			DriveFiles.count({ userHost: group }),
-			DriveFiles.clacDriveUsageOfHost(group),
+			DriveFiles.calcDriveUsageOfHost(group),
 		]);
 
 		return {
diff --git a/src/services/chart/charts/classes/per-user-drive.ts b/src/services/chart/charts/classes/per-user-drive.ts
index 822f4eda0f..aed9f6fce7 100644
--- a/src/services/chart/charts/classes/per-user-drive.ts
+++ b/src/services/chart/charts/classes/per-user-drive.ts
@@ -24,7 +24,7 @@ export default class PerUserDriveChart extends Chart<PerUserDriveLog> {
 	protected async fetchActual(group: string): Promise<DeepPartial<PerUserDriveLog>> {
 		const [count, size] = await Promise.all([
 			DriveFiles.count({ userId: group }),
-			DriveFiles.clacDriveUsageOf(group)
+			DriveFiles.calcDriveUsageOf(group)
 		]);
 
 		return {
diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts
index 8d32d06d2e..b5085ec8e3 100644
--- a/src/services/drive/add-file.ts
+++ b/src/services/drive/add-file.ts
@@ -334,7 +334,7 @@ export default async function(
 
 	//#region Check drive usage
 	if (user && !isLink) {
-		const usage = await DriveFiles.clacDriveUsageOf(user);
+		const usage = await DriveFiles.calcDriveUsageOf(user);
 
 		const instance = await fetchMeta();
 		const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);