From 361069314ffaa61a81b2189c2eec000a3d1d9c35 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Fri, 17 Sep 2021 22:39:15 +0900
Subject: [PATCH] Refine UI (#7806)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update default.vue

* wip
---
 src/client/components/global/avatar.vue |  26 ++++
 src/client/components/ui/folder.vue     |   3 +-
 src/client/components/ui/input.vue      |   2 +-
 src/client/components/ui/textarea.vue   |   2 +-
 src/client/init.ts                      |   4 +-
 src/client/pages/emojis.category.vue    | 134 +++++++++++++++++++++
 src/client/pages/emojis.emoji.vue       |  92 +++++++++++++++
 src/client/pages/emojis.vue             | 139 ++--------------------
 src/client/pages/favorites.vue          |   3 +-
 src/client/pages/note.vue               | 150 ++++++++++++------------
 src/client/pages/notifications.vue      |   1 +
 src/client/pages/settings/index.vue     |   3 +-
 src/client/pages/timeline.vue           | 128 ++++++--------------
 src/client/pages/user/index.vue         |  93 ++++-----------
 src/client/style.scss                   |   1 -
 src/client/themes/_dark.json5           |   2 +-
 src/client/themes/_light.json5          |   2 +-
 src/client/ui/_common_/header.vue       |  99 ++++++++++++++--
 src/client/ui/default.vue               |  11 +-
 src/client/ui/universal.vue             |   8 +-
 20 files changed, 517 insertions(+), 386 deletions(-)
 create mode 100644 src/client/pages/emojis.category.vue
 create mode 100644 src/client/pages/emojis.emoji.vue

diff --git a/src/client/components/global/avatar.vue b/src/client/components/global/avatar.vue
index eea970ec9a..395ed5d8ce 100644
--- a/src/client/components/global/avatar.vue
+++ b/src/client/components/global/avatar.vue
@@ -73,6 +73,22 @@ export default defineComponent({
 </script>
 
 <style lang="scss" scoped>
+@keyframes earwiggleleft {
+	from { transform: rotate(37.6deg) skew(30deg); }
+	25% { transform: rotate(10deg) skew(30deg); }
+	50% { transform: rotate(20deg) skew(30deg); }
+	75% { transform: rotate(0deg) skew(30deg); }
+	to { transform: rotate(37.6deg) skew(30deg); }
+}
+
+@keyframes earwiggleright {
+	from { transform: rotate(-37.6deg) skew(-30deg); }
+	30% { transform: rotate(-10deg) skew(-30deg); }
+	55% { transform: rotate(-20deg) skew(-30deg); }
+	75% { transform: rotate(0deg) skew(-30deg); }
+	to { transform: rotate(-37.6deg) skew(-30deg); }
+}
+
 .eiwwqkts {
 	position: relative;
 	display: inline-block;
@@ -132,6 +148,16 @@ export default defineComponent({
 			border-radius: 75% 0 75% 75%;
 			transform: rotate(-37.5deg) skew(-30deg);
 		}
+
+		&:hover {
+			&:before {
+				animation: earwiggleleft 1s infinite;
+			}
+
+			&:after {
+				animation: earwiggleright 1s infinite;
+			}
+		}
 	}
 }
 </style>
diff --git a/src/client/components/ui/folder.vue b/src/client/components/ui/folder.vue
index 1f3593a74a..eecf1d8be1 100644
--- a/src/client/components/ui/folder.vue
+++ b/src/client/components/ui/folder.vue
@@ -99,7 +99,8 @@ export default defineComponent({
 		z-index: 10;
 		position: sticky;
 		top: var(--stickyTop, 0px);
-		background: var(--panel);
+		padding: var(--x-padding);
+		background: var(--x-header, var(--panel));
 		/* TODO panelの半透明バージョンをプログラマティックに作りたい
 		background: var(--X17);
 		-webkit-backdrop-filter: var(--blur, blur(8px));
diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue
index 05ce5d3e15..a916a0b035 100644
--- a/src/client/components/ui/input.vue
+++ b/src/client/components/ui/input.vue
@@ -245,7 +245,7 @@ export default defineComponent({
 			font-size: 1em;
 			color: var(--fg);
 			background: var(--panel);
-			border: solid 1px var(--inputBorder);
+			border: solid 0.5px var(--inputBorder);
 			border-radius: 6px;
 			outline: none;
 			box-shadow: none;
diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue
index 53a141f011..08ac3182a9 100644
--- a/src/client/components/ui/textarea.vue
+++ b/src/client/components/ui/textarea.vue
@@ -212,7 +212,7 @@ export default defineComponent({
 			font-size: 1em;
 			color: var(--fg);
 			background: var(--panel);
-			border: solid 1px var(--inputBorder);
+			border: solid 0.5px var(--inputBorder);
 			border-radius: 6px;
 			outline: none;
 			box-shadow: none;
diff --git a/src/client/init.ts b/src/client/init.ts
index 4d2170e03f..aa9cd817c4 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -15,7 +15,7 @@ if (localStorage.getItem('accounts') != null) {
 
 import * as Sentry from '@sentry/browser';
 import { Integrations } from '@sentry/tracing';
-import { computed, createApp, watch, markRaw } from 'vue';
+import { computed, createApp, watch, markRaw, version as vueVersion } from 'vue';
 import compareVersions from 'compare-versions';
 
 import widgets from '@client/widgets';
@@ -47,6 +47,8 @@ window.onunhandledrejection = null;
 if (_DEV_) {
 	console.warn('Development mode!!!');
 
+	console.info(`vue ${vueVersion}`);
+
 	(window as any).$i = $i;
 	(window as any).$store = defaultStore;
 
diff --git a/src/client/pages/emojis.category.vue b/src/client/pages/emojis.category.vue
new file mode 100644
index 0000000000..0c24b06d16
--- /dev/null
+++ b/src/client/pages/emojis.category.vue
@@ -0,0 +1,134 @@
+<template>
+<div class="driuhtrh">
+	<div class="query">
+		<MkInput v-model="q" class="_inputNoTopMargin _inputNoBottomMargin" :placeholder="$ts.search">
+			<template #prefix><i class="fas fa-search"></i></template>
+		</MkInput>
+
+		<div class="tags">
+			<span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
+		</div>
+	</div>
+
+	<MkFolder class="emojis" v-if="searchEmojis">
+		<template #header>{{ $ts.searchResult }}</template>
+		<div class="zuvgdzyt">
+			<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
+		</div>
+	</MkFolder>
+	
+	<MkFolder class="emojis" v-for="category in customEmojiCategories" :key="category">
+		<template #header>{{ category || $ts.other }}</template>
+		<div class="zuvgdzyt">
+			<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
+		</div>
+	</MkFolder>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, computed } from 'vue';
+import MkButton from '@client/components/ui/button.vue';
+import MkInput from '@client/components/ui/input.vue';
+import MkSelect from '@client/components/ui/select.vue';
+import MkFolder from '@client/components/ui/folder.vue';
+import MkTab from '@client/components/tab.vue';
+import * as os from '@client/os';
+import * as symbols from '@client/symbols';
+import { emojiCategories, emojiTags } from '@client/instance';
+import XEmoji from './emojis.emoji.vue';
+
+export default defineComponent({
+	components: {
+		MkButton,
+		MkInput,
+		MkSelect,
+		MkFolder,
+		MkTab,
+		XEmoji,
+	},
+
+	data() {
+		return {
+			q: '',
+			customEmojiCategories: emojiCategories,
+			customEmojis: this.$instance.emojis,
+			tags: emojiTags,
+			selectedTags: new Set(),
+			searchEmojis: null,
+		}
+	},
+
+	watch: {
+		q() { this.search(); },
+		selectedTags: {
+			handler() {
+				this.search();
+			},
+			deep: true
+		},
+	},
+
+	methods: {
+		search() {
+			if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) {
+				this.searchEmojis = null;
+				return;
+			}
+
+			if (this.selectedTags.size === 0) {
+				this.searchEmojis = this.customEmojis.filter(e => e.name.includes(this.q) || e.aliases.includes(this.q));
+			} else {
+				this.searchEmojis = this.customEmojis.filter(e => (e.name.includes(this.q) || e.aliases.includes(this.q)) && [...this.selectedTags].every(t => e.aliases.includes(t)));
+			}
+		},
+
+		toggleTag(tag) {
+			if (this.selectedTags.has(tag)) {
+				this.selectedTags.delete(tag);
+			} else {
+				this.selectedTags.add(tag);
+			}
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.driuhtrh {
+	background: var(--bg);
+
+	> .query {
+		background: var(--bg);
+		padding: 16px;
+
+		> .tags {
+			> .tag {
+				display: inline-block;
+				margin: 8px 8px 0 0;
+				padding: 4px 8px;
+				font-size: 0.9em;
+				background: var(--panel);
+				border: solid 0.5px var(--divider);
+				border-radius: 5px;
+
+				&.active {
+					border-color: var(--accent);
+				}
+			}
+		}
+	}
+
+	> .emojis {
+		--x-header: var(--bg);
+		--x-padding: 0 16px;
+
+		.zuvgdzyt {
+			display: grid;
+			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+			grid-gap: 12px;
+			margin: 0 var(--margin) var(--margin) var(--margin);
+		}
+	}
+}
+</style>
diff --git a/src/client/pages/emojis.emoji.vue b/src/client/pages/emojis.emoji.vue
new file mode 100644
index 0000000000..3c9bb4debe
--- /dev/null
+++ b/src/client/pages/emojis.emoji.vue
@@ -0,0 +1,92 @@
+<template>
+<button class="zuvgdzyu _button" @click="menu">
+	<img :src="emoji.url" class="img" :alt="emoji.name"/>
+	<div class="body">
+		<div class="name _monospace">{{ emoji.name }}</div>
+		<div class="info">{{ emoji.aliases.join(' ') }}</div>
+	</div>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@client/os';
+import copyToClipboard from '@client/scripts/copy-to-clipboard';
+import VanillaTilt from 'vanilla-tilt';
+
+export default defineComponent({
+	props: {
+		emoji: {
+			type: Object,
+			required: true,
+		}
+	},
+
+	mounted() {
+		VanillaTilt.init(this.$el, {
+			reverse: true,
+			gyroscope: false,
+			scale: 1.1,
+			speed: 500,
+		});
+	},
+
+	methods: {
+		menu(ev) {
+			os.popupMenu([{
+				type: 'label',
+				text: ':' + this.emoji.name + ':',
+			}, {
+				text: this.$ts.copy,
+				icon: 'fas fa-copy',
+				action: () => {
+					copyToClipboard(`:${this.emoji.name}:`);
+					os.success();
+				}
+			}], ev.currentTarget || ev.target);
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.zuvgdzyu {
+	display: flex;
+	align-items: center;
+	padding: 12px;
+	text-align: left;
+	background: var(--panel);
+	border-radius: 8px;
+	transform-style: preserve-3d;
+	transform: perspective(1000px);
+
+	&:hover {
+		border-color: var(--accent);
+	}
+
+	> .img {
+		width: 42px;
+		height: 42px;
+		transform: translateZ(20px);
+	}
+
+	> .body {
+		padding: 0 0 0 8px;
+		white-space: nowrap;
+		overflow: hidden;
+		transform: translateZ(10px);
+
+		> .name {
+			text-overflow: ellipsis;
+			overflow: hidden;
+		}
+
+		> .info {
+			opacity: 0.5;
+			font-size: 0.9em;
+			text-overflow: ellipsis;
+			overflow: hidden;
+		}
+	}
+}
+</style>
diff --git a/src/client/pages/emojis.vue b/src/client/pages/emojis.vue
index 391aff8297..c1f87047d3 100644
--- a/src/client/pages/emojis.vue
+++ b/src/client/pages/emojis.vue
@@ -1,151 +1,30 @@
 <template>
-<div class="driuhtrh">
-	<div class="query">
-		<MkInput v-model="q" class="_inputNoTopMargin _inputNoBottomMargin" :placeholder="$ts.search">
-			<template #prefix><i class="fas fa-search"></i></template>
-		</MkInput>
-	</div>
-
-	<div class="emojis">
-		<MkFolder v-if="searchEmojis">
-			<template #header>{{ $ts.searchResult }}</template>
-			<div class="zuvgdzyt">
-				<button v-for="emoji in searchEmojis" :key="emoji.name" class="emoji _button" @click="menu(emoji, $event)">
-					<img :src="emoji.url" class="img" :alt="emoji.name"/>
-					<div class="body">
-						<div class="name _monospace">{{ emoji.name }}</div>
-						<div class="info">{{ emoji.aliases.join(' ') }}</div>
-					</div>
-				</button>
-			</div>
-		</MkFolder>
-		<MkFolder v-for="category in customEmojiCategories" :key="category">
-			<template #header>{{ category || $ts.other }}</template>
-			<div class="zuvgdzyt">
-				<button v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji _button" @click="menu(emoji, $event)">
-					<img :src="emoji.url" class="img" :alt="emoji.name"/>
-					<div class="body">
-						<div class="name _monospace">{{ emoji.name }}</div>
-						<div class="info">{{ emoji.aliases.join(' ') }}</div>
-					</div>
-				</button>
-			</div>
-		</MkFolder>
-	</div>
-</div>
+<XCategory v-if="tab === 'category'"/>
 </template>
 
 <script lang="ts">
-import { defineComponent } from 'vue';
-import MkButton from '@client/components/ui/button.vue';
-import MkInput from '@client/components/ui/input.vue';
-import MkSelect from '@client/components/ui/select.vue';
-import MkFolder from '@client/components/ui/folder.vue';
+import { defineComponent, computed } from 'vue';
 import * as os from '@client/os';
 import * as symbols from '@client/symbols';
-import { emojiCategories } from '@client/instance';
-import copyToClipboard from '@client/scripts/copy-to-clipboard';
+import XCategory from './emojis.category.vue';
 
 export default defineComponent({
 	components: {
-		MkButton,
-		MkInput,
-		MkSelect,
-		MkFolder,
+		XCategory,
 	},
 
 	data() {
 		return {
-			[symbols.PAGE_INFO]: {
+			[symbols.PAGE_INFO]: computed(() => ({
 				title: this.$ts.customEmojis,
-				icon: 'fas fa-laugh'
-			},
-			q: '',
-			customEmojiCategories: emojiCategories,
-			customEmojis: this.$instance.emojis,
-			searchEmojis: null,
+				icon: 'fas fa-laugh',
+				bg: 'var(--bg)',
+			})),
+			tab: 'category',
 		}
 	},
-
-	watch: {
-		q() {
-			if (this.q === '' || this.q == null) {
-				this.searchEmojis = null;
-				return;
-			}
-
-			this.searchEmojis = this.customEmojis.filter(e => e.name.includes(this.q) || e.aliases.includes(this.q));
-		}
-	},
-
-	methods: {
-		menu(emoji, ev) {
-			os.popupMenu([{
-				type: 'label',
-				text: ':' + emoji.name + ':',
-			}, {
-				text: this.$ts.copy,
-				icon: 'fas fa-copy',
-				action: () => {
-					copyToClipboard(`:${emoji.name}:`);
-					os.success();
-				}
-			}], ev.currentTarget || ev.target);
-		}
-	}
 });
 </script>
 
 <style lang="scss" scoped>
-.driuhtrh {
-	> .query {
-		background: var(--bg);
-		padding: 16px;
-	}
-
-	> .emojis {
-		.zuvgdzyt {
-			display: grid;
-			grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
-			grid-gap: 12px;
-			margin: 0 var(--margin) var(--margin) var(--margin);
-
-			> .emoji {
-				display: flex;
-				align-items: center;
-				padding: 12px;
-				text-align: left;
-				border: solid 1px var(--divider);
-				border-radius: 8px;
-
-				&:hover {
-					border-color: var(--accent);
-				}
-
-				> .img {
-					width: 42px;
-					height: 42px;
-				}
-
-				> .body {
-					padding: 0 0 0 8px;
-					white-space: nowrap;
-					overflow: hidden;
-
-					> .name {
-						text-overflow: ellipsis;
-						overflow: hidden;
-					}
-
-					> .info {
-						opacity: 0.5;
-						font-size: 0.9em;
-						text-overflow: ellipsis;
-						overflow: hidden;
-					}
-				}
-			}
-		}
-	}
-}
 </style>
diff --git a/src/client/pages/favorites.vue b/src/client/pages/favorites.vue
index a2d61b98d9..f13723c2d1 100644
--- a/src/client/pages/favorites.vue
+++ b/src/client/pages/favorites.vue
@@ -22,7 +22,8 @@ export default defineComponent({
 		return {
 			[symbols.PAGE_INFO]: {
 				title: this.$ts.favorites,
-				icon: 'fas fa-star'
+				icon: 'fas fa-star',
+				bg: 'var(--bg)',
 			},
 			pagination: {
 				endpoint: 'i/favorites',
diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue
index 7725ca14b4..fe85d7364e 100644
--- a/src/client/pages/note.vue
+++ b/src/client/pages/note.vue
@@ -1,37 +1,39 @@
 <template>
-<div class="fcuexfpr _root">
-	<transition name="fade" mode="out-in">
-		<div v-if="note" class="note">
-			<div class="_gap" v-if="showNext">
-				<XNotes class="_content" :pagination="next" :no-gap="true"/>
-			</div>
-
-			<div class="main _gap">
-				<MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton>
-				<div class="note _gap">
-					<MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_isolated"/>
-					<XNoteDetailed v-model:note="note" :key="note.id" class="_isolated note"/>
+<div class="fcuexfpr">
+	<div class="_root">
+		<transition name="fade" mode="out-in">
+			<div v-if="note" class="note">
+				<div class="_gap" v-if="showNext">
+					<XNotes class="_content" :pagination="next" :no-gap="true"/>
 				</div>
-				<div class="_content clips _gap" v-if="clips && clips.length > 0">
-					<div class="title">{{ $ts.clip }}</div>
-					<MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
-						<b>{{ item.name }}</b>
-						<div v-if="item.description" class="description">{{ item.description }}</div>
-						<div class="user">
-							<MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/>
-						</div>
-					</MkA>
-				</div>
-				<MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton>
-			</div>
 
-			<div class="_gap" v-if="showPrev">
-				<XNotes class="_content" :pagination="prev" :no-gap="true"/>
+				<div class="main _gap">
+					<MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton>
+					<div class="note _gap">
+						<MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_isolated"/>
+						<XNoteDetailed v-model:note="note" :key="note.id" class="_isolated note"/>
+					</div>
+					<div class="_content clips _gap" v-if="clips && clips.length > 0">
+						<div class="title">{{ $ts.clip }}</div>
+						<MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+							<b>{{ item.name }}</b>
+							<div v-if="item.description" class="description">{{ item.description }}</div>
+							<div class="user">
+								<MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/>
+							</div>
+						</MkA>
+					</div>
+					<MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton>
+				</div>
+
+				<div class="_gap" v-if="showPrev">
+					<XNotes class="_content" :pagination="prev" :no-gap="true"/>
+				</div>
 			</div>
-		</div>
-		<MkError v-else-if="error" @retry="fetch()"/>
-		<MkLoading v-else/>
-	</transition>
+			<MkError v-else-if="error" @retry="fetch()"/>
+			<MkLoading v-else/>
+		</transition>
+	</div>
 </div>
 </template>
 
@@ -63,12 +65,14 @@ export default defineComponent({
 		return {
 			[symbols.PAGE_INFO]: computed(() => this.note ? {
 				title: this.$ts.note,
+				subtitle: new Date(this.note.createdAt).toLocaleString(),
 				avatar: this.note.user,
 				path: `/notes/${this.note.id}`,
 				share: {
 					title: this.$t('noteOf', { user: this.note.user.name }),
 					text: this.note.text,
 				},
+				bg: 'var(--bg)',
 			} : null),
 			note: null,
 			clips: null,
@@ -149,52 +153,54 @@ export default defineComponent({
 .fcuexfpr {
 	background: var(--bg);
 
-	> .note {
-		> .main {
-			> .load {
-				min-width: 0;
-				margin: 0 auto;
-				border-radius: 999px;
+	> ._root {
+		> .note {
+			> .main {
+				> .load {
+					min-width: 0;
+					margin: 0 auto;
+					border-radius: 999px;
 
-				&.next {
-					margin-bottom: var(--margin);
-				}
-
-				&.prev {
-					margin-top: var(--margin);
-				}
-			}
-
-			> .note {
-				> .note {
-					border-radius: var(--radius);
-					background: var(--panel);
-				}
-			}
-
-			> .clips {
-				> .title {
-					font-weight: bold;
-					padding: 12px;
-				}
-
-				> .item {
-					display: block;
-					padding: 16px;
-
-					> .description {
-						padding: 8px 0;
+					&.next {
+						margin-bottom: var(--margin);
 					}
 
-					> .user {
-						$height: 32px;
-						padding-top: 16px;
-						border-top: solid 0.5px var(--divider);
-						line-height: $height;
+					&.prev {
+						margin-top: var(--margin);
+					}
+				}
 
-						> .avatar {
-							width: $height;
-							height: $height;
+				> .note {
+					> .note {
+						border-radius: var(--radius);
+						background: var(--panel);
+					}
+				}
+
+				> .clips {
+					> .title {
+						font-weight: bold;
+						padding: 12px;
+					}
+
+					> .item {
+						display: block;
+						padding: 16px;
+
+						> .description {
+							padding: 8px 0;
+						}
+
+						> .user {
+							$height: 32px;
+							padding-top: 16px;
+							border-top: solid 0.5px var(--divider);
+							line-height: $height;
+
+							> .avatar {
+								width: $height;
+								height: $height;
+							}
 						}
 					}
 				}
diff --git a/src/client/pages/notifications.vue b/src/client/pages/notifications.vue
index 633718a90b..06f8ad3cba 100644
--- a/src/client/pages/notifications.vue
+++ b/src/client/pages/notifications.vue
@@ -21,6 +21,7 @@ export default defineComponent({
 			[symbols.PAGE_INFO]: {
 				title: this.$ts.notifications,
 				icon: 'fas fa-bell',
+				bg: 'var(--bg)',
 				actions: [{
 					text: this.$ts.markAllAsRead,
 					icon: 'fas fa-check',
diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue
index e7e2506020..3fb5f5f1e6 100644
--- a/src/client/pages/settings/index.vue
+++ b/src/client/pages/settings/index.vue
@@ -86,7 +86,8 @@ export default defineComponent({
 	setup(props, context) {
 		const indexInfo = {
 			title: i18n.locale.settings,
-			icon: 'fas fa-cog'
+			icon: 'fas fa-cog',
+			bg: 'var(--bg)',
 		};
 		const INFO = ref(indexInfo);
 		const page = ref(props.initialPage);
diff --git a/src/client/pages/timeline.vue b/src/client/pages/timeline.vue
index f54549b982..125191223c 100644
--- a/src/client/pages/timeline.vue
+++ b/src/client/pages/timeline.vue
@@ -1,25 +1,10 @@
 <template>
 <div class="cmuxhskf" v-hotkey.global="keymap" v-size="{ min: [800] }">
-	<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block _isolated"/>
-	<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block _isolated" fixed/>
-	<div class="tabs">
-		<div class="left">
-			<button class="_button tab" @click="() => { src = 'home'; saveSrc(); }" :class="{ active: src === 'home' }" v-tooltip="$ts._timelines.home"><i class="fas fa-home"></i></button>
-			<button class="_button tab" @click="() => { src = 'local'; saveSrc(); }" :class="{ active: src === 'local' }" v-tooltip="$ts._timelines.local" v-if="isLocalTimelineAvailable"><i class="fas fa-comments"></i></button>
-			<button class="_button tab" @click="() => { src = 'social'; saveSrc(); }" :class="{ active: src === 'social' }" v-tooltip="$ts._timelines.social" v-if="isLocalTimelineAvailable"><i class="fas fa-share-alt"></i></button>
-			<button class="_button tab" @click="() => { src = 'global'; saveSrc(); }" :class="{ active: src === 'global' }" v-tooltip="$ts._timelines.global" v-if="isGlobalTimelineAvailable"><i class="fas fa-globe"></i></button>
-			<span class="divider"></span>
-			<button class="_button tab" @click="() => { src = 'mentions'; saveSrc(); }" :class="{ active: src === 'mentions' }" v-tooltip="$ts.mentions"><i class="fas fa-at"></i><i v-if="$i.hasUnreadMentions" class="fas fa-circle i"></i></button>
-			<button class="_button tab" @click="() => { src = 'directs'; saveSrc(); }" :class="{ active: src === 'directs' }" v-tooltip="$ts.directNotes"><i class="fas fa-envelope"></i><i v-if="$i.hasUnreadSpecifiedNotes" class="fas fa-circle i"></i></button>
-		</div>
-		<div class="right">
-			<button class="_button tab" @click="chooseChannel" :class="{ active: src === 'channel' }" v-tooltip="$ts.channel"><i class="fas fa-satellite-dish"></i><i v-if="$i.hasUnreadChannel" class="fas fa-circle i"></i></button>
-			<button class="_button tab" @click="chooseAntenna" :class="{ active: src === 'antenna' }" v-tooltip="$ts.antennas"><i class="fas fa-satellite"></i><i v-if="$i.hasUnreadAntenna" class="fas fa-circle i"></i></button>
-			<button class="_button tab" @click="chooseList" :class="{ active: src === 'list' }" v-tooltip="$ts.lists"><i class="fas fa-list-ul"></i></button>
-		</div>
-	</div>
+	<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
+	<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
+
 	<div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
-	<div class="tl">
+	<div class="tl _block">
 		<XTimeline ref="tl" class="tl"
 			:key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src === 'channel' ? `channel:${channel.id}` : src"
 			:src="src"
@@ -63,12 +48,37 @@ export default defineComponent({
 			queue: 0,
 			[symbols.PAGE_INFO]: computed(() => ({
 				title: this.$ts.timeline,
-				subtitle: this.src === 'local' ? this.$ts._timelines.local : this.src === 'social' ? this.$ts._timelines.social : this.src === 'global' ? this.$ts._timelines.global : this.$ts._timelines.home,
 				icon: this.src === 'local' ? 'fas fa-comments' : this.src === 'social' ? 'fas fa-share-alt' : this.src === 'global' ? 'fas fa-globe' : 'fas fa-home',
+				bg: 'var(--bg)',
 				actions: [{
 					icon: 'fas fa-calendar-alt',
 					text: this.$ts.jumpToSpecifiedDate,
 					handler: this.timetravel
+				}],
+				tabs: [{
+					active: this.src === 'home',
+					title: this.$ts._timelines.home,
+					icon: 'fas fa-home',
+					iconOnly: true,
+					onClick: () => { this.src = 'home'; this.saveSrc(); },
+				}, {
+					active: this.src === 'local',
+					title: this.$ts._timelines.local,
+					icon: 'fas fa-comments',
+					iconOnly: true,
+					onClick: () => { this.src = 'local'; this.saveSrc(); },
+				}, {
+					active: this.src === 'social',
+					title: this.$ts._timelines.social,
+					icon: 'fas fa-share-alt',
+					iconOnly: true,
+					onClick: () => { this.src = 'social'; this.saveSrc(); },
+				}, {
+					active: this.src === 'global',
+					title: this.$ts._timelines.global,
+					icon: 'fas fa-globe',
+					iconOnly: true,
+					onClick: () => { this.src = 'global'; this.saveSrc(); },
 				}]
 			})),
 		};
@@ -213,6 +223,8 @@ export default defineComponent({
 
 <style lang="scss" scoped>
 .cmuxhskf {
+	padding: var(--margin);
+
 	> .new {
 		position: sticky;
 		top: calc(var(--stickyTop, 0px) + 16px);
@@ -227,79 +239,15 @@ export default defineComponent({
 		}
 	}
 
-	> .tabs {
-		display: flex;
-		box-sizing: border-box;
-		padding: 0 8px;
-		white-space: nowrap;
-		overflow: auto;
-		border-bottom: solid 0.5px var(--divider);
-
-		// 影の都合上
-		position: relative;
-
-		> .right {
-			margin-left: auto;
-		}
-
-		> .left, > .right {
-			> .tab {
-				position: relative;
-				height: 50px;
-				padding: 0 12px;
-
-				&:hover {
-					color: var(--fgHighlighted);
-				}
-
-				&.active {
-					color: var(--fgHighlighted);
-
-					&:after {
-						content: "";
-						display: block;
-						position: absolute;
-						bottom: 0;
-						left: 0;
-						right: 0;
-						margin: 0 auto;
-						width: 100%;
-						height: 2px;
-						background: var(--accent);
-					}
-				}
-
-				> .i {
-					position: absolute;
-					top: 16px;
-					right: 8px;
-					color: var(--indicator);
-					font-size: 8px;
-					animation: blink 1s infinite;
-				}
-			}
-
-			> .divider {
-				display: inline-block;
-				width: 1px;
-				height: 28px;
-				vertical-align: middle;
-				margin: 0 8px;
-				background: var(--divider);
-			}
-		}
+	> .tl {
+		background: var(--bg);
+		border-radius: var(--radius);
+		overflow: clip;
 	}
 
 	&.min-width_800px {
-		> .tl {
-			background: var(--bg);
-			padding: 32px 0;
-
-			> .tl {
-				max-width: 800px;
-				margin: 0 auto;
-			}
-		}
+		max-width: 800px;
+		margin: 0 auto;
 	}
 }
 </style>
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
index 4145c86d56..86dc7361b5 100644
--- a/src/client/pages/user/index.vue
+++ b/src/client/pages/user/index.vue
@@ -60,23 +60,9 @@
 				<XPhotos :user="user" :key="user.id" class="_gap"/>
 			</div>
 			<div class="main">
-				<div class="nav _gap">
-					<MkA :to="userPage(user)" :class="{ active: page === 'index' }" class="link">
-						<i class="fas fa-comment-alt icon"></i>
-						<span>{{ $ts.notes }}</span>
-					</MkA>
-					<MkA :to="userPage(user, 'clips')" :class="{ active: page === 'clips' }" class="link">
-						<i class="fas fa-paperclip icon"></i>
-						<span>{{ $ts.clips }}</span>
-					</MkA>
-					<MkA :to="userPage(user, 'pages')" :class="{ active: page === 'pages' }" class="link">
-						<i class="fas fa-file-alt icon"></i>
-						<span>{{ $ts.pages }}</span>
-					</MkA>
-					<div class="actions">
-						<button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button>
-						<MkFollowButton v-if="!$i || $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
-					</div>
+				<div class="actions">
+					<button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button>
+					<MkFollowButton v-if="!$i || $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
 				</div>
 				<template v-if="page === 'index'">
 					<div v-if="user.pinnedNotes.length > 0" class="_gap">
@@ -178,25 +164,6 @@
 		</div>
 
 		<div class="contents">
-			<div class="nav _gap">
-				<MkA :to="userPage(user)" :class="{ active: page === 'index' }" class="link" v-click-anime>
-					<i class="fas fa-comment-alt icon"></i>
-					<span>{{ $ts.notes }}</span>
-				</MkA>
-				<MkA :to="userPage(user, 'clips')" :class="{ active: page === 'clips' }" class="link" v-click-anime>
-					<i class="fas fa-paperclip icon"></i>
-					<span>{{ $ts.clips }}</span>
-				</MkA>
-				<MkA :to="userPage(user, 'pages')" :class="{ active: page === 'pages' }" class="link" v-click-anime>
-					<i class="fas fa-file-alt icon"></i>
-					<span>{{ $ts.pages }}</span>
-				</MkA>
-				<MkA :to="userPage(user, 'gallery')" :class="{ active: page === 'gallery' }" class="link" v-click-anime>
-					<i class="fas fa-icons icon"></i>
-					<span>{{ $ts.gallery }}</span>
-				</MkA>
-			</div>
-
 			<template v-if="page === 'index'">
 				<div>
 					<div v-if="user.pinnedNotes.length > 0" class="_gap">
@@ -283,6 +250,27 @@ export default defineComponent({
 				share: {
 					title: this.user.name,
 				},
+				bg: 'var(--bg)',
+				tabs: [{
+					active: this.page === 'index',
+					title: this.$ts.overview,
+					icon: 'fas fa-home',
+				}, {
+					active: this.page === 'clips',
+					title: this.$ts.clips,
+					icon: 'fas fa-paperclip',
+					onClick: () => { this.page = 'clips'; },
+				}, {
+					active: this.page === 'pages',
+					title: this.$ts.pages,
+					icon: 'fas fa-file-alt',
+					onClick: () => { this.page = 'pages'; },
+				}, {
+					active: this.page === 'gallery',
+					title: this.$ts.gallery,
+					icon: 'fas fa-icons',
+					onClick: () => { this.page = 'gallery'; },
+				}]
 			} : null),
 			user: null,
 			error: null,
@@ -314,7 +302,7 @@ export default defineComponent({
 
 	mounted() {
 		window.requestAnimationFrame(this.parallaxLoop);
-		this.narrow = this.$el.clientWidth < 1000;
+		this.narrow = true//this.$el.clientWidth < 1000;
 	},
 
 	beforeUnmount() {
@@ -772,37 +760,6 @@ export default defineComponent({
 	}
 
 	> .contents {
-		> .nav {
-			display: flex;
-			align-items: center;
-			font-size: 90%;
-
-			> .link {
-				flex: 1;
-				display: inline-block;
-				padding: 16px;
-				text-align: center;
-				border-bottom: solid 3px transparent;
-
-				&:hover {
-					text-decoration: none;
-				}
-
-				&.active {
-					color: var(--accent);
-					border-bottom-color: var(--accent);
-				}
-
-				&:not(.active):hover {
-					color: var(--fgHighlighted);
-				}
-
-				> .icon {
-					margin-right: 6px;
-				}
-			}
-		}
-
 		> .content {
 			margin-bottom: var(--margin);
 		}
diff --git a/src/client/style.scss b/src/client/style.scss
index 6ab5e796bd..0318013f60 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -245,7 +245,6 @@ hr {
 ._panel {
 	background: var(--panel);
 	border-radius: var(--radius);
-	border: var(--panelBorder);
 	overflow: clip;
 }
 
diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5
index ca9994d5e9..b4553ee812 100644
--- a/src/client/themes/_dark.json5
+++ b/src/client/themes/_dark.json5
@@ -36,7 +36,7 @@
 		navFg: '@fg',
 		navHoverFg: ':lighten<17<@fg',
 		navActive: '@accent',
-		navIndicator: '@accent',
+		navIndicator: '@indicator',
 		link: '#44a4c1',
 		hashtag: '#ff9156',
 		mention: '@accent',
diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5
index 973a6251f0..104f5a85af 100644
--- a/src/client/themes/_light.json5
+++ b/src/client/themes/_light.json5
@@ -36,7 +36,7 @@
 		navFg: '@fg',
 		navHoverFg: ':darken<17<@fg',
 		navActive: '@accent',
-		navIndicator: '@accent',
+		navIndicator: '@indicator',
 		link: '#44a4c1',
 		hashtag: '#ff9156',
 		mention: '@accent',
diff --git a/src/client/ui/_common_/header.vue b/src/client/ui/_common_/header.vue
index 115f70a540..eb8a1b9c0a 100644
--- a/src/client/ui/_common_/header.vue
+++ b/src/client/ui/_common_/header.vue
@@ -1,25 +1,35 @@
 <template>
-<div class="fdidabkb" :class="{ center }" :style="`--height:${height};`" :key="key">
+<div class="fdidabkb" :class="{ slim: titleOnly || narrow }" :style="`--height:${height};`" :key="key">
 	<transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear>
 		<div class="buttons left" v-if="backButton">
 			<button class="_button button back" @click.stop="$emit('back')" @touchstart="preventDrag" v-tooltip="$ts.goBack"><i class="fas fa-chevron-left"></i></button>
 		</div>
 	</transition>
 	<template v-if="info">
-		<div class="titleContainer">
+		<div class="titleContainer" @click="showTabsPopup">
 			<i v-if="info.icon" class="icon" :class="info.icon"></i>
 			<MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
 
 			<div class="title">
 				<MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/>
 				<div v-else-if="info.title" class="title">{{ info.title }}</div>
-				<div class="subtitle" v-if="info.subtitle">
+				<div class="subtitle" v-if="!narrow && info.subtitle">
 					{{ info.subtitle }}
 				</div>
+				<div class="subtitle activeTab" v-if="narrow && hasTabs">
+					{{ info.tabs.find(tab => tab.active)?.title }}
+					<i class="chevron fas fa-chevron-down"></i>
+				</div>
 			</div>
 		</div>
+		<div class="tabs" v-if="!narrow">
+			<button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title">
+				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+			</button>
+		</div>
 		<div class="buttons right">
-			<template v-if="info.actions && showActions">
+			<template v-if="info.actions && !narrow">
 				<button v-for="action in info.actions" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button>
 			</template>
 			<button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button>
@@ -52,24 +62,28 @@ export default defineComponent({
 			required: false,
 			default: false,
 		},
-		center: {
+		titleOnly: {
 			type: Boolean,
 			required: false,
-			default: true,
+			default: false,
 		},
 	},
 
 	data() {
 		return {
-			showActions: false,
+			narrow: false,
 			height: 0,
 			key: 0,
 		};
 	},
 
 	computed: {
+		hasTabs(): boolean {
+			return this.info.tabs && this.info.tabs.length > 0;
+		},
+
 		shouldShowMenu() {
-			if (this.info.actions != null && !this.showActions) return true;
+			if (this.info.actions != null && this.narrow) return true;
 			if (this.info.menu != null) return true;
 			if (this.info.share != null) return true;
 			if (this.menu != null) return true;
@@ -85,10 +99,10 @@ export default defineComponent({
 
 	mounted() {
 		this.height = this.$el.parentElement.offsetHeight + 'px';
-		this.showActions = this.$el.parentElement.offsetWidth >= 500;
+		this.narrow = this.titleOnly || this.$el.parentElement.offsetWidth < 500;
 		new ResizeObserver((entries, observer) => {
 			this.height = this.$el.parentElement.offsetHeight + 'px';
-			this.showActions = this.$el.parentElement.offsetWidth >= 500;
+			this.narrow = this.titleOnly || this.$el.parentElement.offsetWidth < 500;
 		}).observe(this.$el);
 	},
 
@@ -102,7 +116,7 @@ export default defineComponent({
 
 		showMenu(ev) {
 			let menu = this.info.menu ? this.info.menu() : [];
-			if (!this.showActions && this.info.actions) {
+			if (this.narrow && this.info.actions) {
 				menu = [...this.info.actions.map(x => ({
 					text: x.text,
 					icon: x.icon,
@@ -124,6 +138,18 @@ export default defineComponent({
 			popupMenu(menu, ev.currentTarget || ev.target);
 		},
 
+		showTabsPopup(ev) {
+			if (!this.hasTabs) return;
+			ev.preventDefault();
+			ev.stopPropagation();
+			const menu = this.info.tabs.map(tab => ({
+				text: tab.title,
+				icon: tab.icon,
+				action: tab.onClick,
+			}));
+			popupMenu(menu, ev.currentTarget || ev.target);
+		},
+
 		preventDrag(ev) {
 			ev.stopPropagation();
 		}
@@ -135,7 +161,7 @@ export default defineComponent({
 .fdidabkb {
 	display: flex;
 
-	&.center {
+	&.slim {
 		text-align: center;
 
 		> .titleContainer {
@@ -190,6 +216,7 @@ export default defineComponent({
 		overflow: auto;
 		white-space: nowrap;
 		text-align: left;
+		font-weight: bold;
 
 		> .avatar {
 			$size: 32px;
@@ -219,6 +246,54 @@ export default defineComponent({
 				white-space: nowrap;
 				overflow: hidden;
 				text-overflow: ellipsis;
+
+				&.activeTab {
+					text-align: center;
+
+					> .chevron {
+						display: inline-block;
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+
+	> .tabs {
+		margin-left: 16px;
+		font-size: 0.8em;
+
+		> .tab {
+			display: inline-block;
+			position: relative;
+			padding: 0 10px;
+			height: 100%;
+			font-weight: normal;
+			opacity: 0.7;
+
+			&:hover {
+				opacity: 1;
+			}
+
+			&.active {
+				opacity: 1;
+
+				&:after {
+					content: "";
+					display: block;
+					position: absolute;
+					bottom: 0;
+					left: 0;
+					right: 0;
+					margin: 0 auto;
+					width: 100%;
+					height: 3px;
+					background: var(--accent);
+				}
+			}
+
+			> .icon + .title {
+				margin-left: 8px;
 			}
 		}
 	}
diff --git a/src/client/ui/default.vue b/src/client/ui/default.vue
index eef693faef..a5ec243e9e 100644
--- a/src/client/ui/default.vue
+++ b/src/client/ui/default.vue
@@ -12,7 +12,7 @@
 			</div>
 		</template>
 
-		<main class="main" @contextmenu.stop="onContextmenu">
+		<main class="main" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }">
 			<header class="header" @click="onHeaderClick">
 				<XHeader :info="pageInfo" :back-button="true" @back="back()"/>
 			</header>
@@ -145,6 +145,15 @@ export default defineComponent({
 					}
 				}, '*');
 			}, { passive: true });
+			window.addEventListener('touchmove', ev => {
+				this.$refs.live2d.contentWindow.postMessage({
+					type: 'moveCursor',
+					body: {
+						x: ev.touches[0].clientX - iframeRect.left,
+						y: ev.touches[0].clientY - iframeRect.top,
+					}
+				}, '*');
+			}, { passive: true });
 		}
 	},
 
diff --git a/src/client/ui/universal.vue b/src/client/ui/universal.vue
index d6cace0f41..cc754cba70 100644
--- a/src/client/ui/universal.vue
+++ b/src/client/ui/universal.vue
@@ -2,8 +2,8 @@
 <div class="mk-app" :class="{ wallpaper }">
 	<XSidebar ref="nav" class="sidebar"/>
 
-	<div class="contents" ref="contents" @contextmenu.stop="onContextmenu">
-		<header class="header" ref="header" @click="onHeaderClick">
+	<div class="contents" ref="contents" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }">
+		<header class="header" ref="header" @click="onHeaderClick" :style="{ background: pageInfo?.bg }">
 			<XHeader :info="pageInfo" :back-button="true" @back="back()"/>
 		</header>
 		<main ref="main">
@@ -258,7 +258,6 @@ export default defineComponent({
 	}
 
 	> .sidebar {
-		border-right: solid 0.5px var(--divider);
 	}
 
 	> .contents {
@@ -313,7 +312,8 @@ export default defineComponent({
 
 	> .widgets {
 		padding: 0 var(--margin);
-		border-left: solid 0.5px var(--divider);
+		//border-left: solid 0.5px var(--divider);
+		background: var(--navBg);
 
 		@media (max-width: $widgets-hide-threshold) {
 			display: none;