From cf3fc97202588e835ade5d6ab1a3c087e46958ad Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 11 Jul 2020 10:13:11 +0900
Subject: [PATCH] Deck (#6504)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
---
 locales/ja-JP.yml                             |  15 +
 src/client/app.vue                            | 474 +----------------
 src/client/components/deck/antenna-column.vue |  80 +++
 src/client/components/deck/column-core.vue    |  50 ++
 src/client/components/deck/column.vue         | 426 +++++++++++++++
 src/client/components/deck/direct-column.vue  |  39 ++
 src/client/components/deck/list-column.vue    |  87 ++++
 .../components/deck/mentions-column.vue       |  39 ++
 .../components/deck/notifications-column.vue  |  69 +++
 src/client/components/deck/tl-column.vue      | 141 +++++
 src/client/components/deck/widgets-column.vue | 151 ++++++
 src/client/components/error.vue               |   2 +-
 src/client/components/form-window.vue         |  71 +++
 src/client/components/modal.vue               |   9 +-
 src/client/components/note-header.vue         |   1 -
 src/client/components/note.vue                | 118 ++---
 src/client/components/sidebar.vue             | 488 ++++++++++++++++++
 src/client/components/timeline.vue            |  12 +-
 src/client/components/ui/container.vue        |  38 +-
 src/client/components/ui/input.vue            |  15 +-
 src/client/components/ui/select.vue           |   6 +-
 src/client/components/ui/switch.vue           |   2 +-
 src/client/components/ui/textarea.vue         |   4 +-
 src/client/components/window.vue              |   7 +-
 src/client/config.ts                          |   1 +
 src/client/deck.vue                           | 312 +++++++++++
 src/client/init.ts                            |  18 +-
 src/client/mios.ts                            |   3 -
 src/client/pages/index.home.vue               |   2 +-
 src/client/pages/note.vue                     |   7 +-
 src/client/pages/preferences/index.vue        |  28 +-
 src/client/pages/user/index.timeline.vue      |  12 +-
 src/client/pages/user/index.vue               |  90 ++--
 src/client/scripts/form.ts                    |  26 +
 src/client/scripts/paging.ts                  |   2 +-
 src/client/scripts/scroll.ts                  |   4 +-
 src/client/store.ts                           | 154 +++++-
 src/client/style.scss                         |  46 +-
 src/client/themes/_dark.json5                 |   5 +-
 src/client/themes/_light.json5                |   5 +-
 src/client/themes/black.json5                 |   1 +
 src/client/themes/lilac.json5                 |   2 +
 src/client/themes/rainy.json5                 |   1 +
 src/client/themes/white.json5                 |   4 +
 src/client/widgets/activity.vue               |  47 +-
 src/client/widgets/calendar.vue               |  15 +-
 src/client/widgets/clock.vue                  |  23 +-
 src/client/widgets/define.ts                  |  34 +-
 src/client/widgets/digital-clock.vue          |  75 +++
 src/client/widgets/index.ts                   |  14 +
 src/client/widgets/memo.vue                   |  27 +-
 src/client/widgets/notifications.vue          |  70 +--
 src/client/widgets/photos.vue                 |  44 +-
 src/client/widgets/rss.vue                    |  49 +-
 src/client/widgets/timeline.vue               |  98 ++--
 src/client/widgets/trends.vue                 |  39 +-
 56 files changed, 2695 insertions(+), 907 deletions(-)
 create mode 100644 src/client/components/deck/antenna-column.vue
 create mode 100644 src/client/components/deck/column-core.vue
 create mode 100644 src/client/components/deck/column.vue
 create mode 100644 src/client/components/deck/direct-column.vue
 create mode 100644 src/client/components/deck/list-column.vue
 create mode 100644 src/client/components/deck/mentions-column.vue
 create mode 100644 src/client/components/deck/notifications-column.vue
 create mode 100644 src/client/components/deck/tl-column.vue
 create mode 100644 src/client/components/deck/widgets-column.vue
 create mode 100644 src/client/components/form-window.vue
 create mode 100644 src/client/components/sidebar.vue
 create mode 100644 src/client/deck.vue
 create mode 100644 src/client/scripts/form.ts
 create mode 100644 src/client/widgets/digital-clock.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 2df16db6a..ab4b10549 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -519,6 +519,8 @@ fixedWidgetsPosition: "ウィジェットの位置を固定する"
 enablePlayer: "プレイヤーを開く"
 disablePlayer: "プレイヤーを閉じる"
 expandTweet: "ツイートを展開する"
+deck: "デッキ"
+undeck: "デッキ解除"
 
 _theme:
   explore: "テーマを探す"
@@ -651,6 +653,7 @@ _widgets:
   rss: "RSSリーダー"
   activity: "アクティビティ"
   photos: "フォト"
+  digitalClock: "デジタル時計"
 
 _cw:
   hide: "隠す"
@@ -1129,3 +1132,15 @@ _notification:
   yourFollowRequestAccepted: "フォローリクエストが承認されました"
   youWereInvitedToGroup: "グループに招待されました"
 
+_deck:
+  alwaysShowMainColumn: "常にメインカラムを表示"
+  columnAlign: "カラムの寄せ"
+
+  _columns:
+    widgets: "ウィジェット"
+    notifications: "通知"
+    tl: "タイムライン"
+    antenna: "アンテナ"
+    list: "リスト"
+    mentions: "あなた宛て"
+    direct: "ダイレクト"
diff --git a/src/client/app.vue b/src/client/app.vue
index f1a834049..4f3918356 100644
--- a/src/client/app.vue
+++ b/src/client/app.vue
@@ -29,47 +29,7 @@
 		</div>
 	</header>
 
-	<transition name="nav-back">
-		<div class="nav-back"
-			v-if="showNav"
-			@click="showNav = false"
-			@touchstart="showNav = false"
-		></div>
-	</transition>
-
-	<transition name="nav">
-		<nav class="nav" ref="nav" v-show="showNav">
-			<div>
-				<button class="item _button account" @click="openAccountMenu" v-if="$store.getters.isSignedIn">
-					<mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/>
-				</button>
-				<button class="item _button index active" @click="top()" v-if="$route.name === 'index'">
-					<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
-				</button>
-				<router-link class="item index" active-class="active" to="/" exact v-else>
-					<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
-				</router-link>
-				<template v-for="item in menu">
-					<div v-if="item === '-'" class="divider"></div>
-					<component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to">
-						<fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span>
-						<i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i>
-					</component>
-				</template>
-				<div class="divider"></div>
-				<button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu">
-					<fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span>
-				</button>
-				<button class="item _button" @click="more">
-					<fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span>
-					<i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i>
-				</button>
-				<router-link class="item" active-class="active" to="/preferences">
-					<fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span>
-				</router-link>
-			</div>
-		</nav>
-	</transition>
+	<x-sidebar ref="nav"/>
 
 	<div class="contents" ref="contents" :class="{ wallpaper }">
 		<main ref="main">
@@ -103,20 +63,20 @@
 								<span class="handle"><fa :icon="faBars"/></span>{{ $t('_widgets.' + widget.name) }}<button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button>
 							</header>
 							<div @click="widgetFunc(widget.id)">
-								<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/>
+								<component class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/>
 							</div>
 						</div>
 					</x-draggable>
 				</div>
 				<div class="container" v-else>
-					<component class="_widget" v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/>
+					<component v-for="widget in widgets[place]" class="_close_ _forceContainerFull_" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget"/>
 				</div>
 			</div>
 		</template>
 	</div>
 
 	<div class="buttons">
-		<button class="button nav _button" @click="showNav = true" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button>
+		<button class="button nav _button" @click="showNav" ref="navButton"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button>
 		<button v-if="$route.name === 'index'" class="button home _button" @click="top()"><fa :icon="faHome"/></button>
 		<button v-else class="button home _button" @click="$router.push('/')"><fa :icon="faHome"/></button>
 		<button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="$router.push('/my/notifications')"><fa :icon="faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button>
@@ -135,14 +95,17 @@ import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt,
 import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
 import { ResizeObserver } from '@juggle/resize-observer';
 import { v4 as uuid } from 'uuid';
-import { host, instanceName } from './config';
+import { host } from './config';
 import { search } from './scripts/search';
 import { StickySidebar } from './scripts/sticky-sidebar';
+import { widgets } from './widgets';
+import XSidebar from './components/sidebar.vue';
 
 const DESKTOP_THRESHOLD = 1100;
 
 export default Vue.extend({
 	components: {
+		XSidebar,
 		XClock: () => import('./components/header-clock.vue').then(m => m.default),
 		MkButton: () => import('./components/ui/button.vue').then(m => m.default),
 		XDraggable: () => import('vuedraggable'),
@@ -152,19 +115,14 @@ export default Vue.extend({
 		return {
 			host: host,
 			pageKey: 0,
-			showNav: false,
 			searching: false,
-			accounts: [],
-			lists: [],
 			connection: null,
 			searchQuery: '',
 			searchWait: false,
 			widgetsEditMode: false,
-			menuDef: this.$store.getters.nav({
-				search: this.search
-			}),
 			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 			canBack: false,
+			menuDef: this.$store.getters.nav({}),
 			wallpaper: localStorage.getItem('wallpaper') != null,
 			faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
 		};
@@ -210,30 +168,19 @@ export default Vue.extend({
 			return this.$store.state.deviceUser.menu;
 		},
 
-		otherNavItemIndicated(): boolean {
-			if (!this.$store.getters.isSignedIn) return false;
-			for (const def in this.menuDef) {
-				if (this.menu.includes(def)) continue;
-				if (this.menuDef[def].indicated) return true;
-			}
-			return false;
-		},
-
 		navIndicated(): boolean {
 			if (!this.$store.getters.isSignedIn) return false;
 			for (const def in this.menuDef) {
-				if (def === 'timeline') continue;
-				if (def === 'notifications') continue;
+				if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから
 				if (this.menuDef[def].indicated) return true;
 			}
 			return false;
 		}
 	},
 
-	watch:{
+	watch: {
 		$route(to, from) {
 			this.pageKey++;
-			this.showNav = false;
 			this.canBack = (window.history.length > 0 && !['index'].includes(to.name));
 		},
 
@@ -245,6 +192,8 @@ export default Vue.extend({
 	},
 
 	created() {
+		document.documentElement.style.overflowY = 'scroll';
+
 		if (this.$store.getters.isSignedIn) {
 			this.connection = this.$root.stream.useSharedConnection('main');
 			this.connection.on('notification', this.onNotification);
@@ -266,7 +215,7 @@ export default Vue.extend({
 
 	mounted() {
 		const adjustTitlePosition = () => {
-			const left = this.$refs.main.getBoundingClientRect().left - this.$refs.nav.offsetWidth;
+			const left = this.$refs.main.getBoundingClientRect().left - this.$refs.nav.$el.offsetWidth;
 			if (left >= 0) {
 				this.$refs.title.style.left = left + 'px';
 			}
@@ -293,6 +242,10 @@ export default Vue.extend({
 	},
 
 	methods: {
+		showNav() {
+			this.$refs.nav.show();
+		},
+
 		attachSticky() {
 			if (!this.isDesktop) return;
 			if (this.$store.state.device.fixedWidgetsPosition) return;
@@ -351,180 +304,6 @@ export default Vue.extend({
 			}
 		},
 
-		async openAccountMenu(ev) {
-			const accounts = (await this.$root.api('users/show', { userIds: this.$store.state.device.accounts.map(x => x.id) })).filter(x => x.id !== this.$store.state.i.id);
-
-			const accountItems = accounts.map(account => ({
-				type: 'user',
-				user: account,
-				action: () => { this.switchAccount(account); }
-			}));
-
-			this.$root.menu({
-				items: [...[{
-					type: 'link',
-					text: this.$t('profile'),
-					to: `/@${ this.$store.state.i.username }`,
-					avatar: this.$store.state.i,
-				}, {
-					type: 'link',
-					text: this.$t('accountSettings'),
-					to: '/my/settings',
-					icon: faCog,
-				}, null, ...accountItems, {
-					icon: faPlus,
-					text: this.$t('addAcount'),
-					action: () => {
-						this.$root.menu({
-							items: [{
-								text: this.$t('existingAcount'),
-								action: () => { this.addAcount(); },
-							}, {
-								text: this.$t('createAccount'),
-								action: () => { this.createAccount(); },
-							}],
-							align: 'left',
-							fixed: true,
-							width: 240,
-							source: ev.currentTarget || ev.target,
-						});
-					},
-				}]],
-				align: 'left',
-				fixed: true,
-				width: 240,
-				source: ev.currentTarget || ev.target,
-			});
-		},
-
-		oepnInstanceMenu(ev) {
-			this.$root.menu({
-				items: [{
-					type: 'link',
-					text: this.$t('dashboard'),
-					to: '/instance',
-					icon: faTachometerAlt,
-				}, null, {
-					type: 'link',
-					text: this.$t('settings'),
-					to: '/instance/settings',
-					icon: faCog,
-				}, {
-					type: 'link',
-					text: this.$t('customEmojis'),
-					to: '/instance/emojis',
-					icon: faLaugh,
-				}, {
-					type: 'link',
-					text: this.$t('users'),
-					to: '/instance/users',
-					icon: faUsers,
-				}, {
-					type: 'link',
-					text: this.$t('files'),
-					to: '/instance/files',
-					icon: faCloud,
-				}, {
-					type: 'link',
-					text: this.$t('jobQueue'),
-					to: '/instance/queue',
-					icon: faExchangeAlt,
-				}, {
-					type: 'link',
-					text: this.$t('federation'),
-					to: '/instance/federation',
-					icon: faGlobe,
-				}, {
-					type: 'link',
-					text: this.$t('relays'),
-					to: '/instance/relays',
-					icon: faProjectDiagram,
-				}, {
-					type: 'link',
-					text: this.$t('announcements'),
-					to: '/instance/announcements',
-					icon: faBroadcastTower,
-				}],
-				align: 'left',
-				fixed: true,
-				width: 200,
-				source: ev.currentTarget || ev.target,
-			});
-		},
-
-		more(ev) {
-			const items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
-				type: def.to ? 'link' : 'button',
-				text: this.$t(def.title),
-				icon: def.icon,
-				to: def.to,
-				action: def.action,
-				indicate: def.indicated,
-			}));
-			this.$root.menu({
-				items: [...items, null, {
-					type: 'link',
-					text: this.$t('help'),
-					to: '/docs',
-					icon: faQuestionCircle,
-				}, {
-					type: 'link',
-					text: this.$t('aboutX', { x: instanceName || host }),
-					to: '/about',
-					icon: faInfoCircle,
-				}, {
-					type: 'link',
-					text: this.$t('aboutMisskey'),
-					to: '/about-misskey',
-					icon: faInfoCircle,
-				}],
-				align: 'left',
-				fixed: true,
-				width: 200,
-				source: ev.currentTarget || ev.target,
-			});
-		},
-
-		async addAcount() {
-			this.$root.new(await import('./components/signin-dialog.vue').then(m => m.default)).$once('login', res => {
-				this.$store.dispatch('addAcount', res);
-				this.$root.dialog({
-					type: 'success',
-					iconOnly: true, autoClose: true
-				});
-			});
-		},
-
-		async createAccount() {
-			this.$root.new(await import('./components/signup-dialog.vue').then(m => m.default)).$once('signup', res => {
-				this.$store.dispatch('addAcount', res);
-				this.switchAccountWithToken(res.i);
-			});
-		},
-
-		async switchAccount(account: any) {
-			const token = this.$store.state.device.accounts.find((x: any) => x.id === account.id).token;
-			this.switchAccountWithToken(token);
-		},
-
-		switchAccountWithToken(token: string) {
-			this.$root.dialog({
-				type: 'waiting',
-				iconOnly: true
-			});
-
-			this.$root.api('i', {}, token).then((i: any) => {
-				this.$store.dispatch('switchAccount', {
-					...i,
-					token: token
-				}).then(() => {
-					this.$nextTick(() => {
-						location.reload();
-					});
-				});
-			});
-		},
-
 		async onNotification(notification) {
 			if (document.visibilityState === 'visible') {
 				this.$root.stream.send('readNotification', {
@@ -540,8 +319,7 @@ export default Vue.extend({
 		},
 
 		widgetFunc(id) {
-			const w = this.$refs[id][0];
-			if (w.func) w.func();
+			this.$refs[id][0].setting();
 		},
 
 		onWidgetSort() {
@@ -549,18 +327,6 @@ export default Vue.extend({
 		},
 
 		async addWidget(place) {
-			const widgets = [
-				'memo',
-				'notifications',
-				'timeline',
-				'calendar',
-				'rss',
-				'trends',
-				'clock',
-				'activity',
-				'photos',
-			];
-
 			const { canceled, result: widget } = await this.$root.dialog({
 				type: null,
 				title: this.$t('chooseWidget'),
@@ -594,36 +360,14 @@ export default Vue.extend({
 </script>
 
 <style lang="scss" scoped>
-.nav-enter-active,
-.nav-leave-active {
-	opacity: 1;
-	transform: translateX(0);
-	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.nav-enter,
-.nav-leave-active {
-	opacity: 0;
-	transform: translateX(-240px);
-}
-
-.nav-back-enter-active,
-.nav-back-leave-active {
-	opacity: 1;
-	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
-}
-.nav-back-enter,
-.nav-back-leave-active {
-	opacity: 0;
-}
-
 .mk-app {
 	$header-height: 60px;
-	$nav-width: 250px;
-	$nav-icon-only-width: 80px;
+	$nav-width: 250px; // TODO: どこかに集約したい
+	$nav-icon-only-width: 80px; // TODO: どこかに集約したい
 	$main-width: 670px;
-	$ui-font-size: 1em;
-	$nav-icon-only-threshold: 1279px;
-	$nav-hide-threshold: 650px;
+	$ui-font-size: 1em; // TODO: どこかに集約したい
+	$nav-icon-only-threshold: 1279px; // TODO: どこかに集約したい
+	$nav-hide-threshold: 650px; // TODO: どこかに集約したい
 	$header-sub-hide-threshold: 1090px;
 	$left-widgets-hide-threshold: 1600px;
 	$right-widgets-hide-threshold: 1090px;
@@ -780,176 +524,6 @@ export default Vue.extend({
 		}
 	}
 
-	> .nav-back {
-		position: fixed;
-		top: 0;
-		left: 0;
-		z-index: 1001;
-		width: 100%;
-		height: 100%;
-		background: var(--modalBg);
-	}
-
-	> .nav {
-		$avatar-size: 32px;
-		$avatar-margin: ($header-height - $avatar-size) / 2;
-
-		flex: 0 0 $nav-width;
-		width: $nav-width;
-		box-sizing: border-box;
-
-		@media (max-width: $nav-icon-only-threshold) {
-			flex: 0 0 $nav-icon-only-width;
-			width: $nav-icon-only-width;
-		}
-
-		@media (max-width: $nav-hide-threshold) {
-			position: fixed;
-			top: 0;
-			left: 0;
-			z-index: 1001;
-		}
-
-		@media (min-width: $nav-hide-threshold + 1px) {
-			display: block !important;
-		}
-
-		> div {
-			position: fixed;
-			top: 0;
-			left: 0;
-			z-index: 1001;
-			width: $nav-width;
-			height: 100vh;
-			box-sizing: border-box;
-			overflow: auto;
-			background: var(--navBg);
-			border-right: solid 1px var(--divider);
-
-			> .divider {
-				margin: 16px 0;
-				border-top: solid 1px var(--divider);
-			}
-
-			@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
-				width: $nav-icon-only-width;
-
-				> .divider {
-					margin: 8px auto;
-					width: calc(100% - 32px);
-				}
-
-				> .item {
-					&:first-child {
-						margin-bottom: 8px;
-					}
-
-					&:last-child {
-						margin-top: 8px;
-					}
-				}
-			}
-
-			> .item {
-				position: relative;
-				display: block;
-				padding-left: 32px;
-				font-size: $ui-font-size;
-				line-height: 3.2rem;
-				text-overflow: ellipsis;
-				overflow: hidden;
-				white-space: nowrap;
-				width: 100%;
-				text-align: left;
-				box-sizing: border-box;
-				color: var(--navFg);
-
-				> [data-icon] {
-					width: ($header-height - ($avatar-margin * 2));
-				}
-
-				> [data-icon],
-				> .avatar {
-					margin-right: $avatar-margin;
-				}
-
-				> .avatar {
-					width: $avatar-size;
-					height: $avatar-size;
-					vertical-align: middle;
-				}
-
-				> i {
-					position: absolute;
-					top: 0;
-					left: 20px;
-					color: var(--navIndicator);
-					font-size: 8px;
-					animation: blink 1s infinite;
-				}
-
-				&:hover {
-					text-decoration: none;
-					color: var(--navHoverFg);
-				}
-
-				&.active {
-					color: var(--navActive);
-				}
-
-				&:first-child, &:last-child {
-					position: sticky;
-					z-index: 1;
-					padding-top: 8px;
-					padding-bottom: 8px;
-					background: var(--X14);
-					-webkit-backdrop-filter: blur(8px);
-					backdrop-filter: blur(8px);
-				}
-
-				&:first-child {
-					top: 0;
-					margin-bottom: 16px;
-					border-bottom: solid 1px var(--divider);
-				}
-
-				&:last-child {
-					bottom: 0;
-					margin-top: 16px;
-					border-top: solid 1px var(--divider);
-				}
-
-				@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
-					padding-left: 0;
-					width: 100%;
-					text-align: center;
-					font-size: $ui-font-size * 1.2;
-					line-height: 3.7rem;
-
-					> [data-icon],
-					> .avatar {
-						margin-right: 0;
-					}
-
-					> i {
-						left: 10px;
-					}
-
-					> .text {
-						display: none;
-					}
-				}
-			}
-
-			@media (max-width: $nav-hide-threshold) {
-				> .index,
-				> .notifications {
-					display: none;
-				}
-			}
-		}
-	}
-
 	> .contents {
 		display: flex;
 		margin: 0 auto;
diff --git a/src/client/components/deck/antenna-column.vue b/src/client/components/deck/antenna-column.vue
new file mode 100644
index 000000000..83fe14f2c
--- /dev/null
+++ b/src/client/components/deck/antenna-column.vue
@@ -0,0 +1,80 @@
+<template>
+<x-column :menu="menu" :column="column" :is-stacked="isStacked">
+	<template #header>
+		<fa :icon="faSatellite"/><span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<x-timeline ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSatellite, faCog } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XTimeline from '../timeline.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XTimeline,
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			menu: null,
+			faSatellite
+		};
+	},
+
+	watch: {
+		mediaOnly() {
+			(this.$refs.timeline as any).reload();
+		}
+	},
+
+	created() {
+		this.menu = [{
+			icon: faCog,
+			text: this.$t('antenna'),
+			action: async () => {
+				const antennas = await this.$root.api('antennas/list');
+				this.$root.dialog({
+					title: this.$t('antenna'),
+					type: null,
+					select: {
+						items: antennas.map(x => ({
+							value: x, text: x.name
+						}))
+					},
+					showCancelButton: true
+				}).then(({ canceled, result: antenna }) => {
+					if (canceled) return;
+					this.column.antennaId = antenna.id;
+					this.$store.commit('deviceUser/updateDeckColumn', this.column);
+				});
+			}
+		}];
+	},
+
+	methods: {
+		focus() {
+			(this.$refs.timeline as any).focus();
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/src/client/components/deck/column-core.vue b/src/client/components/deck/column-core.vue
new file mode 100644
index 000000000..44f19e7ed
--- /dev/null
+++ b/src/client/components/deck/column-core.vue
@@ -0,0 +1,50 @@
+<template>
+<!-- TODO: リファクタの余地がありそう -->
+<x-widgets-column v-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<x-notifications-column v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<x-tl-column v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<x-list-column v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<x-antenna-column v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<!-- TODO: <x-tl-column v-else-if="column.type === 'hashtag'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> -->
+<x-mentions-column v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+<x-direct-column v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" v-on="$listeners"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XTlColumn from './tl-column.vue';
+import XAntennaColumn from './antenna-column.vue';
+import XListColumn from './list-column.vue';
+import XNotificationsColumn from './notifications-column.vue';
+import XWidgetsColumn from './widgets-column.vue';
+import XMentionsColumn from './mentions-column.vue';
+import XDirectColumn from './direct-column.vue';
+
+export default Vue.extend({
+	components: {
+		XTlColumn,
+		XAntennaColumn,
+		XListColumn,
+		XNotificationsColumn,
+		XWidgetsColumn,
+		XMentionsColumn,
+		XDirectColumn
+	},
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: false,
+			default: false
+		}
+	},
+	methods: {
+		focus() {
+			this.$children[0].focus();
+		}
+	}
+});
+</script>
diff --git a/src/client/components/deck/column.vue b/src/client/components/deck/column.vue
new file mode 100644
index 000000000..f7620e574
--- /dev/null
+++ b/src/client/components/deck/column.vue
@@ -0,0 +1,426 @@
+<template>
+<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+<section class="dnpfarvg _panel _narrow_" :class="{ naked, paged: isMainColumn, _close_: !isMainColumn, active, isStacked, draghover, dragging, dropready }"
+	@dragover.prevent.stop="onDragover"
+	@dragleave="onDragleave"
+	@drop.prevent.stop="onDrop"
+	v-hotkey="keymap"
+	:style="{ width: `${width}px` }"
+>
+	<header :class="{ indicated }"
+		draggable="true"
+		@click="goTop"
+		@dragstart="onDragstart"
+		@dragend="onDragend"
+		@contextmenu.prevent.stop="onContextmenu"
+	>
+		<button class="toggleActive _button" @click="toggleActive" v-if="isStacked">
+			<template v-if="active"><fa :icon="faAngleUp"/></template>
+			<template v-else><fa :icon="faAngleDown"/></template>
+		</button>
+		<div class="action">
+			<slot name="action"></slot>
+		</div>
+		<span class="header"><slot name="header"></slot></span>
+		<button v-if="!isMainColumn" class="menu _button" ref="menu" @click.stop="showMenu"><fa :icon="faCaretDown"/></button>
+		<button v-else-if="$route.name !== 'index'" class="close _button" @click.stop="close"><fa :icon="faTimes"/></button>
+	</header>
+	<div ref="body" v-show="active">
+		<slot></slot>
+	</div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes, faArrowRight, faArrowLeft, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
+import { faWindowMaximize, faTrashAlt, faWindowRestore } from '@fortawesome/free-regular-svg-icons';
+
+export default Vue.extend({
+	props: {
+		column: {
+			type: Object,
+			required: false,
+			default: null
+		},
+		isStacked: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		menu: {
+			type: Array,
+			required: false,
+			default: null
+		},
+		naked: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+		indicated: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
+	},
+
+	data() {
+		return {
+			active: true,
+			dragging: false,
+			draghover: false,
+			dropready: false,
+			faArrowUp, faArrowDown, faAngleUp, faAngleDown, faCaretDown, faTimes,
+		};
+	},
+
+	computed: {
+		isMainColumn(): boolean {
+			return this.column == null;
+		},
+
+		width(): number {
+			return this.isMainColumn ? 350 : this.column.width;
+		},
+
+		keymap(): any {
+			return {
+				'shift+up': () => this.$parent.$emit('parentFocus', 'up'),
+				'shift+down': () => this.$parent.$emit('parentFocus', 'down'),
+				'shift+left': () => this.$parent.$emit('parentFocus', 'left'),
+				'shift+right': () => this.$parent.$emit('parentFocus', 'right'),
+			};
+		}
+	},
+
+	watch: {
+		active(v) {
+			this.$emit('change-active-state', v);
+		},
+
+		dragging(v) {
+			this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd');
+		}
+	},
+
+	mounted() {
+		if (!this.isMainColumn) {
+			this.$root.$on('deck.column.dragStart', this.onOtherDragStart);
+			this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd);
+		}
+	},
+
+	beforeDestroy() {
+		if (!this.isMainColumn) {
+			this.$root.$off('deck.column.dragStart', this.onOtherDragStart);
+			this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd);
+		}
+	},
+
+	methods: {
+		onOtherDragStart() {
+			this.dropready = true;
+		},
+
+		onOtherDragEnd() {
+			this.dropready = false;
+		},
+
+		toggleActive() {
+			if (!this.isStacked) return;
+			this.active = !this.active;
+		},
+
+		getMenu() {
+			const items = [{
+				icon: faPencilAlt,
+				text: this.$t('rename'),
+				action: () => {
+					this.$root.dialog({
+						title: this.$t('rename'),
+						input: {
+							default: this.column.name,
+							allowEmpty: false
+						}
+					}).then(({ canceled, result: name }) => {
+						if (canceled) return;
+						this.$store.commit('deviceUser/renameDeckColumn', { id: this.column.id, name });
+					});
+				}
+			}, null, {
+				icon: faArrowLeft,
+				text: this.$t('swap-left'),
+				action: () => {
+					this.$store.commit('deviceUser/swapLeftDeckColumn', this.column.id);
+				}
+			}, {
+				icon: faArrowRight,
+				text: this.$t('swap-right'),
+				action: () => {
+					this.$store.commit('deviceUser/swapRightDeckColumn', this.column.id);
+				}
+			}, this.isStacked ? {
+				icon: faArrowUp,
+				text: this.$t('swap-up'),
+				action: () => {
+					this.$store.commit('deviceUser/swapUpDeckColumn', this.column.id);
+				}
+			} : undefined, this.isStacked ? {
+				icon: faArrowDown,
+				text: this.$t('swap-down'),
+				action: () => {
+					this.$store.commit('deviceUser/swapDownDeckColumn', this.column.id);
+				}
+			} : undefined, null, {
+				icon: faWindowRestore,
+				text: this.$t('stack-left'),
+				action: () => {
+					this.$store.commit('deviceUser/stackLeftDeckColumn', this.column.id);
+				}
+			}, this.isStacked ? {
+				icon: faWindowMaximize,
+				text: this.$t('pop-right'),
+				action: () => {
+					this.$store.commit('deviceUser/popRightDeckColumn', this.column.id);
+				}
+			} : undefined, null, {
+				icon: faTrashAlt,
+				text: this.$t('remove'),
+				action: () => {
+					this.$store.commit('deviceUser/removeDeckColumn', this.column.id);
+				}
+			}];
+
+			if (this.menu) {
+				for (const i of this.menu.reverse()) {
+					items.unshift(i);
+				}
+			}
+
+			return items;
+		},
+
+		onContextmenu(e) {
+			if (this.isMainColumn) return;
+			this.showMenu();
+		},
+
+		showMenu() {
+			this.$root.menu({
+				items: this.getMenu(),
+				source: this.$refs.menu,
+			});
+		},
+
+		close() {
+			this.$router.push('/');
+		},
+
+		goTop() {
+			this.$refs.body.scrollTo({
+				top: 0,
+				behavior: 'smooth'
+			});
+		},
+
+		onDragstart(e) {
+			// メインカラムはドラッグさせない
+			if (this.isMainColumn) {
+				e.preventDefault();
+				return;
+			}
+
+			e.dataTransfer.effectAllowed = 'move';
+			e.dataTransfer.setData('mk-deck-column', this.column.id);
+			this.dragging = true;
+		},
+
+		onDragend(e) {
+			this.dragging = false;
+		},
+
+		onDragover(e) {
+			// メインカラムにはドロップさせない
+			if (this.isMainColumn) {
+				e.dataTransfer.dropEffect = 'none';
+				return;
+			}
+
+			// 自分自身がドラッグされている場合
+			if (this.dragging) {
+				// 自分自身にはドロップさせない
+				e.dataTransfer.dropEffect = 'none';
+				return;
+			}
+
+			const isDeckColumn = e.dataTransfer.types[0] == 'mk-deck-column';
+
+			e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
+
+			if (!this.dragging && isDeckColumn) this.draghover = true;
+		},
+
+		onDragleave() {
+			this.draghover = false;
+		},
+
+		onDrop(e) {
+			this.draghover = false;
+			this.$root.$emit('deck.column.dragEnd');
+
+			const id = e.dataTransfer.getData('mk-deck-column');
+			if (id != null && id != '') {
+				this.$store.commit('deviceUser/swapDeckColumn', {
+					a: this.column.id,
+					b: id
+				});
+			}
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.dnpfarvg {
+	$header-height: 42px;
+
+	height: 100%;
+	overflow: hidden;
+	box-shadow: 0 0 0 1px var(--deckColumnBorder);
+
+	&.draghover {
+		box-shadow: 0 0 0 2px var(--focus);
+
+		&:after {
+			content: "";
+			display: block;
+			position: absolute;
+			z-index: 1000;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			background: var(--focus);
+		}
+	}
+
+	&.dragging {
+		box-shadow: 0 0 0 2px var(--focus);
+	}
+
+	&.dropready {
+		* {
+			pointer-events: none;
+		}
+	}
+
+	&:not(.active) {
+		flex-basis: $header-height;
+		min-height: $header-height;
+
+		> header.indicated {
+			box-shadow: 4px 0px var(--accent) inset;
+		}
+	}
+
+	&.naked {
+		//background: var(--deckAcrylicColumnBg);
+		background: transparent !important;
+
+		> header {
+			background: transparent;
+			box-shadow: none;
+
+			> button {
+				color: var(--fg);
+			}
+		}
+	}
+
+	&.paged {
+		> div {
+			background: var(--bg);
+			padding: var(--margin);
+		}
+	}
+
+	> header {
+		position: relative;
+		display: flex;
+		z-index: 2;
+		line-height: $header-height;
+		padding: 0 16px;
+		font-size: 0.9em;
+		color: var(--panelHeaderFg);
+		background: var(--panelHeaderBg);
+		box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
+		cursor: pointer;
+
+		&, * {
+			user-select: none;
+		}
+
+		&.indicated {
+			box-shadow: 0 3px 0 0 var(--accent);
+		}
+
+		> .header {
+			display: inline-block;
+			align-items: center;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+		}
+
+		> span:only-of-type {
+			width: 100%;
+		}
+
+		> .toggleActive,
+		> .action > *,
+		> .menu,
+		> .close {
+			z-index: 1;
+			width: $header-height;
+			line-height: $header-height;
+			font-size: 16px;
+			color: var(--faceTextButton);
+
+			&:hover {
+				color: var(--faceTextButtonHover);
+			}
+
+			&:active {
+				color: var(--faceTextButtonActive);
+			}
+		}
+
+		> .toggleActive, > .action {
+			margin-left: -16px;
+		}
+
+		> .action {
+			z-index: 1;
+		}
+
+		> .action:empty {
+			display: none;
+		}
+
+		> .menu,
+		> .close {
+			margin-left: auto;
+			margin-right: -16px;
+		}
+	}
+
+	> div {
+		height: calc(100% - #{$header-height});
+		overflow: auto;
+		overflow-x: hidden;
+		-webkit-overflow-scrolling: touch;
+		box-sizing: border-box;
+	}
+}
+</style>
diff --git a/src/client/components/deck/direct-column.vue b/src/client/components/deck/direct-column.vue
new file mode 100644
index 000000000..f340048d6
--- /dev/null
+++ b/src/client/components/deck/direct-column.vue
@@ -0,0 +1,39 @@
+<template>
+<x-column :name="name" :column="column" :is-stacked="isStacked" :menu="menu">
+	<template #header><fa :icon="faEnvelope" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+	<x-direct/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XDirect from '../../pages/messages.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XDirect
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			menu: null,
+			faEnvelope
+		}
+	},
+});
+</script>
diff --git a/src/client/components/deck/list-column.vue b/src/client/components/deck/list-column.vue
new file mode 100644
index 000000000..a3576e8d6
--- /dev/null
+++ b/src/client/components/deck/list-column.vue
@@ -0,0 +1,87 @@
+<template>
+<x-column :menu="menu" :column="column" :is-stacked="isStacked">
+	<template #header>
+		<fa :icon="faListUl"/><span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<x-timeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faListUl, faCog } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XTimeline from '../timeline.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XTimeline,
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			faListUl
+		};
+	},
+
+	watch: {
+		mediaOnly() {
+			(this.$refs.timeline as any).reload();
+		}
+	},
+
+	created() {
+		this.menu = [{
+			icon: faCog,
+			text: this.$t('list'),
+			action: this.setList
+		}];
+	},
+
+	mounted() {
+		if (this.column.listId == null) {
+			this.setList();
+		}
+	},
+
+	methods: {
+		async setList() {
+			const lists = await this.$root.api('users/lists/list');
+			const { canceled, result: list } = await this.$root.dialog({
+				title: this.$t('list'),
+				type: null,
+				select: {
+					items: lists.map(x => ({
+						value: x, text: x.name
+					})),
+					default: this.column.listId
+				},
+				showCancelButton: true
+			});
+			if (canceled) return;
+			Vue.set(this.column, 'listId', list.id);
+			this.$store.commit('deviceUser/updateDeckColumn', this.column);
+		},
+
+		focus() {
+			(this.$refs.timeline as any).focus();
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/src/client/components/deck/mentions-column.vue b/src/client/components/deck/mentions-column.vue
new file mode 100644
index 000000000..19e49d2a8
--- /dev/null
+++ b/src/client/components/deck/mentions-column.vue
@@ -0,0 +1,39 @@
+<template>
+<x-column :column="column" :is-stacked="isStacked" :menu="menu">
+	<template #header><fa :icon="faAt" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+	<x-mentions/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faAt } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XMentions from '../../pages/mentions.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XMentions
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			menu: null,
+			faAt
+		}
+	},
+});
+</script>
diff --git a/src/client/components/deck/notifications-column.vue b/src/client/components/deck/notifications-column.vue
new file mode 100644
index 000000000..58873aa13
--- /dev/null
+++ b/src/client/components/deck/notifications-column.vue
@@ -0,0 +1,69 @@
+<template>
+<x-column :column="column" :is-stacked="isStacked" :menu="menu">
+	<template #header><fa :icon="faBell" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+	<x-notifications/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCog } from '@fortawesome/free-solid-svg-icons';
+import { faBell } from '@fortawesome/free-regular-svg-icons';
+import XColumn from './column.vue';
+import XNotifications from '../notifications.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XNotifications
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			menu: null,
+			faBell
+		}
+	},
+
+	created() {
+		if (this.column.notificationType == null) {
+			this.column.notificationType = 'all';
+			this.$store.commit('deviceUser/updateDeckColumn', this.column);
+		}
+
+		this.menu = [{
+			icon: faCog,
+			text: this.$t('@.notification-type'),
+			action: () => {
+				this.$root.dialog({
+					title: this.$t('@.notification-type'),
+					type: null,
+					select: {
+						items: ['all', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest'].map(x => ({
+							value: x, text: this.$t('@.notification-types.' + x)
+						}))
+						default: this.column.notificationType,
+					},
+					showCancelButton: true
+				}).then(({ canceled, result: type }) => {
+					if (canceled) return;
+					this.column.notificationType = type;
+					this.$store.commit('deviceUser/updateDeckColumn', this.column);
+				});
+			}
+		}];
+	},
+});
+</script>
diff --git a/src/client/components/deck/tl-column.vue b/src/client/components/deck/tl-column.vue
new file mode 100644
index 000000000..c3ee67af3
--- /dev/null
+++ b/src/client/components/deck/tl-column.vue
@@ -0,0 +1,141 @@
+<template>
+<x-column :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState">
+	<template #header>
+		<fa v-if="column.tl === 'home'" :icon="faHome"/>
+		<fa v-else-if="column.tl === 'local'" :icon="faComments"/>
+		<fa v-else-if="column.tl === 'social'" :icon="faShareAlt"/>
+		<fa v-else-if="column.tl === 'global'" :icon="faGlobe"/>
+		<span style="margin-left: 8px;">{{ column.name }}</span>
+	</template>
+
+	<div class="iwaalbte" v-if="disabled">
+		<p>
+			<fa :icon="faMinusCircle"/>
+			{{ $t('disabled-timeline.title') }}
+		</p>
+		<p class="desc">{{ $t('disabled-timeline.description') }}</p>
+	</div>
+	<x-timeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faMinusCircle, faHome, faComments, faShareAlt, faGlobe, faCog } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import XTimeline from '../timeline.vue';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XTimeline,
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true
+		},
+		isStacked: {
+			type: Boolean,
+			required: true
+		}
+	},
+
+	data() {
+		return {
+			menu: null,
+			disabled: false,
+			indicated: false,
+			columnActive: true,
+			faMinusCircle, faHome, faComments, faShareAlt, faGlobe,
+		};
+	},
+
+	watch: {
+		mediaOnly() {
+			(this.$refs.timeline as any).reload();
+		}
+	},
+
+	created() {
+		this.menu = [{
+			icon: faCog,
+			text: this.$t('timeline'),
+			action: this.setType
+		}];
+	},
+
+	mounted() {
+		if (this.column.tl == null) {
+			this.setType();
+		} else {
+			this.disabled = !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin && (
+				this.$store.state.instance.meta.disableLocalTimeline && ['local', 'social'].includes(this.column.tl) ||
+				this.$store.state.instance.meta.disableGlobalTimeline && ['global'].includes(this.column.tl));
+		}
+	},
+
+	methods: {
+		async setType() {
+			const { canceled, result: src } = await this.$root.dialog({
+				title: this.$t('timeline'),
+				type: null,
+				select: {
+					items: [{
+						value: 'home', text: this.$t('_timelines.home')
+					}, {
+						value: 'local', text: this.$t('_timelines.local')
+					}, {
+						value: 'social', text: this.$t('_timelines.social')
+					}, {
+						value: 'global', text: this.$t('_timelines.global')
+					}]
+				},
+				showCancelButton: true
+			});
+			if (canceled) return;
+			Vue.set(this.column, 'tl', src);
+			this.$store.commit('deviceUser/updateDeckColumn', this.column);
+		},
+
+		queueUpdated(q) {
+			if (this.columnActive) {
+				this.indicated = q !== 0;
+			}
+		},
+
+		onNote() {
+			if (!this.columnActive) {
+				this.indicated = true;
+			}
+		},
+
+		onChangeActiveState(state) {
+			this.columnActive = state;
+
+			if (this.columnActive) {
+				this.indicated = false;
+			}
+		},
+
+		focus() {
+			(this.$refs.timeline as any).focus();
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.iwaalbte {
+	text-align: center;
+
+	> p {
+		margin: 16px;
+
+		&.desc {
+			font-size: 14px;
+		}
+	}
+}
+</style>
diff --git a/src/client/components/deck/widgets-column.vue b/src/client/components/deck/widgets-column.vue
new file mode 100644
index 000000000..37b17451e
--- /dev/null
+++ b/src/client/components/deck/widgets-column.vue
@@ -0,0 +1,151 @@
+<template>
+<x-column :menu="menu" :naked="true" :column="column" :is-stacked="isStacked">
+	<template #header><fa :icon="faWindowMaximize" style="margin-right: 8px;"/>{{ column.name }}</template>
+
+	<div class="wtdtxvec">
+		<template v-if="edit">
+			<header>
+				<select v-model="widgetAdderSelected" @change="addWidget">
+					<option v-for="widget in widgets" :value="widget" :key="widget">{{ widget }}</option>
+				</select>
+			</header>
+			<x-draggable
+				:list="column.widgets"
+				animation="150"
+				@sort="onWidgetSort"
+			>
+				<div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @click="widgetFunc(widget.id)">
+					<button class="remove _button" @click="removeWidget(widget)"><fa :icon="faTimes"/></button>
+					<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :column="column"/>
+				</div>
+			</x-draggable>
+		</template>
+		<component v-else class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :column="column"/>
+	</div>
+</x-column>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import { v4 as uuid } from 'uuid';
+import { faWindowMaximize, faTimes } from '@fortawesome/free-solid-svg-icons';
+import XColumn from './column.vue';
+import { widgets } from '../../widgets';
+
+export default Vue.extend({
+	components: {
+		XColumn,
+		XDraggable,
+	},
+
+	props: {
+		column: {
+			type: Object,
+			required: true,
+		},
+		isStacked: {
+			type: Boolean,
+			required: true,
+		},
+	},
+
+	data() {
+		return {
+			edit: false,
+			menu: null,
+			widgetAdderSelected: null,
+			widgets,
+			faWindowMaximize, faTimes
+		};
+	},
+
+	created() {
+		this.menu = [{
+			icon: 'cog',
+			text: this.$t('edit'),
+			action: () => {
+				this.edit = !this.edit;
+			}
+		}];
+	},
+
+	methods: {
+		widgetFunc(id) {
+			this.$refs[id][0].setting();
+		},
+
+		onWidgetSort() {
+			this.saveWidgets();
+		},
+
+		addWidget() {
+			this.$store.commit('deviceUser/addDeckWidget', {
+				id: this.column.id,
+				widget: {
+					name: this.widgetAdderSelected,
+					id: uuid(),
+					data: {}
+				}
+			});
+
+			this.widgetAdderSelected = null;
+		},
+
+		removeWidget(widget) {
+			this.$store.commit('deviceUser/removeDeckWidget', {
+				id: this.column.id,
+				widget
+			});
+		},
+
+		saveWidgets() {
+			this.$store.commit('deviceUser/updateDeckColumn', this.column);
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.wtdtxvec {
+	padding-top: 1px; // ウィジェットのbox-shadowを利用した1px borderを隠さないようにするため
+
+	> header {
+		padding: 16px;
+
+		> * {
+			width: 100%;
+			padding: 4px;
+		}
+	}
+
+	> .widget, .customize-container {
+		margin: 8px;
+
+		&:first-of-type {
+			margin-top: 0;
+		}
+	}
+
+	.customize-container {
+		position: relative;
+		cursor: move;
+
+		> *:not(.remove) {
+			pointer-events: none;
+		}
+
+		> .remove {
+			position: absolute;
+			z-index: 2;
+			top: 8px;
+			right: 8px;
+			width: 32px;
+			height: 32px;
+			color: #fff;
+			background: rgba(#000, 0.7);
+			border-radius: 4px;
+		}
+	}
+}
+</style>
diff --git a/src/client/components/error.vue b/src/client/components/error.vue
index b1d91fb3e..90efa700b 100644
--- a/src/client/components/error.vue
+++ b/src/client/components/error.vue
@@ -40,7 +40,7 @@ export default Vue.extend({
 
 	> img {
 		vertical-align: bottom;
-		height: 150px;
+		height: 128px;
 		margin-bottom: 16px;
 		border-radius: 16px;
 	}
diff --git a/src/client/components/form-window.vue b/src/client/components/form-window.vue
new file mode 100644
index 000000000..25eee9164
--- /dev/null
+++ b/src/client/components/form-window.vue
@@ -0,0 +1,71 @@
+<template>
+<x-window ref="window" :width="400" :height="450" :no-padding="true" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="false" @ok="ok()" :can-close="false">
+	<template #header>
+		{{ title }}
+	</template>
+	<div class="xkpnjxcv">
+		<label v-for="item in Object.keys(form).filter(item => !form[item].hidden)" :key="item">
+			<mk-input v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"><span v-text="form[item].label || item"></span></mk-input>
+			<mk-input v-else-if="form[item].type === 'string' && !item.multiline" v-model="values[item]" type="text"><span v-text="form[item].label || item"></span></mk-input>
+			<mk-textarea v-else-if="form[item].type === 'string' && item.multiline" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-textarea>
+			<mk-switch v-else-if="form[item].type === 'boolean'" v-model="values[item]"><span v-text="form[item].label || item"></span></mk-switch>
+		</label>
+	</div>
+</x-window>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XWindow from './window.vue';
+import MkInput from './ui/input.vue';
+import MkTextarea from './ui/textarea.vue';
+import MkSwitch from './ui/switch.vue';
+
+export default Vue.extend({
+	components: {
+		XWindow,
+		MkInput,
+		MkTextarea,
+		MkSwitch,
+	},
+
+	props: {
+		title: {
+			type: String,
+			required: true,
+		},
+		form: {
+			type: Object,
+			required: true,
+		},
+	},
+
+	data() {
+		return {
+			values: {}
+		};
+	},
+
+	created() {
+		for (const item in this.form) {
+			Vue.set(this.values, item, this.form[item].default || null);
+		}
+	},
+
+	methods: {
+		ok() {
+			this.$emit('ok', this.values);
+			this.$refs.window.close();
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.xkpnjxcv {
+	> label {
+		display: block;
+		padding: 16px 24px;
+	}
+}
+</style>
diff --git a/src/client/components/modal.vue b/src/client/components/modal.vue
index 1a9d98a8c..f941d4d50 100644
--- a/src/client/components/modal.vue
+++ b/src/client/components/modal.vue
@@ -1,10 +1,10 @@
 <template>
 <div class="mk-modal" v-hotkey.global="keymap">
 	<transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear>
-		<div class="bg" ref="bg" v-if="show" @click="close()"></div>
+		<div class="bg" ref="bg" v-if="show" @click="canClose ? close() : () => {}"></div>
 	</transition>
 	<transition :name="$store.state.device.animation ? 'modal' : ''" appear @after-leave="() => { $emit('closed'); destroyDom(); }">
-		<div class="content" ref="content" v-if="show" @click.self="close()"><slot></slot></div>
+		<div class="content" ref="content" v-if="show" @click.self="canClose ? close() : () => {}"><slot></slot></div>
 	</transition>
 </div>
 </template>
@@ -14,6 +14,11 @@ import Vue from 'vue';
 
 export default Vue.extend({
 	props: {
+		canClose: {
+			type: Boolean,
+			required: false,
+			default: true,
+		},
 	},
 	data() {
 		return {
diff --git a/src/client/components/note-header.vue b/src/client/components/note-header.vue
index 93cf2cdf3..039287818 100644
--- a/src/client/components/note-header.vue
+++ b/src/client/components/note-header.vue
@@ -54,7 +54,6 @@ export default Vue.extend({
 		margin: 0 .5em 0 0;
 		padding: 0;
 		overflow: hidden;
-		color: var(--noteHeaderName);
 		font-size: 1em;
 		font-weight: bold;
 		text-decoration: none;
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index 118fef661..badb9f12f 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -724,61 +724,6 @@ export default Vue.extend({
 	transition: box-shadow 0.1s ease;
 	overflow: hidden;
 
-	&.max-width_500px {
-		font-size: 0.9em;
-	}
-
-	&.max-width_450px {
-		> .renote {
-			padding: 8px 16px 0 16px;
-		}
-
-		> .article {
-			padding: 14px 16px 9px;
-
-			> .avatar {
-				margin: 0 10px 8px 0;
-				width: 50px;
-				height: 50px;
-			}
-		}
-	}
-
-	&.max-width_350px {
-		> .article {
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 18px;
-						}
-					}
-				}
-			}
-		}
-	}
-
-	&.max-width_300px {
-		font-size: 0.825em;
-
-		> .article {
-			> .avatar {
-				width: 44px;
-				height: 44px;
-			}
-
-			> .main {
-				> .footer {
-					> .button {
-						&:not(:last-child) {
-							margin-right: 12px;
-						}
-					}
-				}
-			}
-		}
-	}
-
 	&:focus {
 		outline: none;
 		box-shadow: 0 0 0 3px var(--focus);
@@ -797,10 +742,6 @@ export default Vue.extend({
 		white-space: pre;
 		color: #d28a3f;
 
-		@media (max-width: 450px) {
-			padding: 8px 16px 0 16px;
-		}
-
 		> [data-icon] {
 			margin-right: 4px;
 		}
@@ -985,5 +926,64 @@ export default Vue.extend({
 	> .reply {
 		border-top: solid 1px var(--divider);
 	}
+
+	&.max-width_500px {
+		font-size: 0.9em;
+	}
+
+	&.max-width_450px {
+		> .renote {
+			padding: 8px 16px 0 16px;
+		}
+
+		> .info {
+			padding: 8px 16px 0 16px;
+		}
+
+		> .article {
+			padding: 14px 16px 9px;
+
+			> .avatar {
+				margin: 0 10px 8px 0;
+				width: 50px;
+				height: 50px;
+			}
+		}
+	}
+
+	&.max-width_350px {
+		> .article {
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 18px;
+						}
+					}
+				}
+			}
+		}
+	}
+
+	&.max-width_300px {
+		font-size: 0.825em;
+
+		> .article {
+			> .avatar {
+				width: 44px;
+				height: 44px;
+			}
+
+			> .main {
+				> .footer {
+					> .button {
+						&:not(:last-child) {
+							margin-right: 12px;
+						}
+					}
+				}
+			}
+		}
+	}
 }
 </style>
diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue
new file mode 100644
index 000000000..3ddef7d12
--- /dev/null
+++ b/src/client/components/sidebar.vue
@@ -0,0 +1,488 @@
+<template>
+<div class="mvcprjjd">
+	<transition name="nav-back">
+		<div class="nav-back"
+			v-if="showing"
+			@click="showing = false"
+			@touchstart="showing = false"
+		></div>
+	</transition>
+
+	<transition name="nav">
+		<nav class="nav" v-show="showing">
+			<div>
+				<button class="item _button account" @click="openAccountMenu" v-if="$store.getters.isSignedIn">
+					<mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/>
+				</button>
+				<button class="item _button index active" @click="top()" v-if="$route.name === 'index'">
+					<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
+				</button>
+				<router-link class="item index" active-class="active" to="/" exact v-else>
+					<fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
+				</router-link>
+				<template v-for="item in menu">
+					<div v-if="item === '-'" class="divider"></div>
+					<component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'router-link' : 'button'" class="item _button" :class="item" active-class="active" @click="() => { if (menuDef[item].action) menuDef[item].action() }" :to="menuDef[item].to">
+						<fa :icon="menuDef[item].icon" fixed-width/><span class="text">{{ $t(menuDef[item].title) }}</span>
+						<i v-if="menuDef[item].indicated"><fa :icon="faCircle"/></i>
+					</component>
+				</template>
+				<div class="divider"></div>
+				<button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$store.getters.isSignedIn && ($store.state.i.isAdmin || $store.state.i.isModerator)" @click="oepnInstanceMenu">
+					<fa :icon="faServer" fixed-width/><span class="text">{{ $t('instance') }}</span>
+				</button>
+				<button class="item _button" @click="more">
+					<fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span>
+					<i v-if="otherNavItemIndicated"><fa :icon="faCircle"/></i>
+				</button>
+				<router-link class="item" active-class="active" to="/preferences">
+					<fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span>
+				</router-link>
+			</div>
+		</nav>
+	</transition>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faGripVertical, faChevronLeft, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faListUl, faPlus, faUserClock, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faInfoCircle, faQuestionCircle, faProjectDiagram } from '@fortawesome/free-solid-svg-icons';
+import { faBell, faEnvelope, faLaugh, faComments } from '@fortawesome/free-regular-svg-icons';
+import { host, instanceName } from '../config';
+import { search } from '../scripts/search';
+
+export default Vue.extend({
+	data() {
+		return {
+			host: host,
+			showing: false,
+			searching: false,
+			accounts: [],
+			connection: null,
+			menuDef: this.$store.getters.nav({
+				search: this.search
+			}),
+			faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
+		};
+	},
+
+	computed: {
+		menu(): string[] {
+			return this.$store.state.deviceUser.menu;
+		},
+
+		otherNavItemIndicated(): boolean {
+			if (!this.$store.getters.isSignedIn) return false;
+			for (const def in this.menuDef) {
+				if (this.menu.includes(def)) continue;
+				if (this.menuDef[def].indicated) return true;
+			}
+			return false;
+		},
+	},
+
+	watch: {
+		$route(to, from) {
+			this.showing = false;
+		},
+	},
+
+	methods: {
+		show() {
+			this.showing = true;
+		},
+
+		search() {
+			if (this.searching) return;
+
+			this.$root.dialog({
+				title: this.$t('search'),
+				input: true
+			}).then(async ({ canceled, result: query }) => {
+				if (canceled || query == null || query === '') return;
+
+				this.searching = true;
+				search(this, query).finally(() => {
+					this.searching = false;
+				});
+			});
+		},
+
+		async openAccountMenu(ev) {
+			const accounts = (await this.$root.api('users/show', { userIds: this.$store.state.device.accounts.map(x => x.id) })).filter(x => x.id !== this.$store.state.i.id);
+
+			const accountItems = accounts.map(account => ({
+				type: 'user',
+				user: account,
+				action: () => { this.switchAccount(account); }
+			}));
+
+			this.$root.menu({
+				items: [...[{
+					type: 'link',
+					text: this.$t('profile'),
+					to: `/@${ this.$store.state.i.username }`,
+					avatar: this.$store.state.i,
+				}, {
+					type: 'link',
+					text: this.$t('accountSettings'),
+					to: '/my/settings',
+					icon: faCog,
+				}, null, ...accountItems, {
+					icon: faPlus,
+					text: this.$t('addAcount'),
+					action: () => {
+						this.$root.menu({
+							items: [{
+								text: this.$t('existingAcount'),
+								action: () => { this.addAcount(); },
+							}, {
+								text: this.$t('createAccount'),
+								action: () => { this.createAccount(); },
+							}],
+							align: 'left',
+							fixed: true,
+							width: 240,
+							source: ev.currentTarget || ev.target,
+						});
+					},
+				}]],
+				align: 'left',
+				fixed: true,
+				width: 240,
+				source: ev.currentTarget || ev.target,
+			});
+		},
+
+		oepnInstanceMenu(ev) {
+			this.$root.menu({
+				items: [{
+					type: 'link',
+					text: this.$t('dashboard'),
+					to: '/instance',
+					icon: faTachometerAlt,
+				}, null, {
+					type: 'link',
+					text: this.$t('settings'),
+					to: '/instance/settings',
+					icon: faCog,
+				}, {
+					type: 'link',
+					text: this.$t('customEmojis'),
+					to: '/instance/emojis',
+					icon: faLaugh,
+				}, {
+					type: 'link',
+					text: this.$t('users'),
+					to: '/instance/users',
+					icon: faUsers,
+				}, {
+					type: 'link',
+					text: this.$t('files'),
+					to: '/instance/files',
+					icon: faCloud,
+				}, {
+					type: 'link',
+					text: this.$t('jobQueue'),
+					to: '/instance/queue',
+					icon: faExchangeAlt,
+				}, {
+					type: 'link',
+					text: this.$t('federation'),
+					to: '/instance/federation',
+					icon: faGlobe,
+				}, {
+					type: 'link',
+					text: this.$t('relays'),
+					to: '/instance/relays',
+					icon: faProjectDiagram,
+				}, {
+					type: 'link',
+					text: this.$t('announcements'),
+					to: '/instance/announcements',
+					icon: faBroadcastTower,
+				}],
+				align: 'left',
+				fixed: true,
+				width: 200,
+				source: ev.currentTarget || ev.target,
+			});
+		},
+
+		more(ev) {
+			const items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
+				type: def.to ? 'link' : 'button',
+				text: this.$t(def.title),
+				icon: def.icon,
+				to: def.to,
+				action: def.action,
+				indicate: def.indicated,
+			}));
+			this.$root.menu({
+				items: [...items, null, {
+					type: 'link',
+					text: this.$t('help'),
+					to: '/docs',
+					icon: faQuestionCircle,
+				}, {
+					type: 'link',
+					text: this.$t('aboutX', { x: instanceName || host }),
+					to: '/about',
+					icon: faInfoCircle,
+				}, {
+					type: 'link',
+					text: this.$t('aboutMisskey'),
+					to: '/about-misskey',
+					icon: faInfoCircle,
+				}],
+				align: 'left',
+				fixed: true,
+				width: 200,
+				source: ev.currentTarget || ev.target,
+			});
+		},
+
+		async addAcount() {
+			this.$root.new(await import('./signin-dialog.vue').then(m => m.default)).$once('login', res => {
+				this.$store.dispatch('addAcount', res);
+				this.$root.dialog({
+					type: 'success',
+					iconOnly: true, autoClose: true
+				});
+			});
+		},
+
+		async createAccount() {
+			this.$root.new(await import('./signup-dialog.vue').then(m => m.default)).$once('signup', res => {
+				this.$store.dispatch('addAcount', res);
+				this.switchAccountWithToken(res.i);
+			});
+		},
+
+		async switchAccount(account: any) {
+			const token = this.$store.state.device.accounts.find((x: any) => x.id === account.id).token;
+			this.switchAccountWithToken(token);
+		},
+
+		switchAccountWithToken(token: string) {
+			this.$root.dialog({
+				type: 'waiting',
+				iconOnly: true
+			});
+
+			this.$root.api('i', {}, token).then((i: any) => {
+				this.$store.dispatch('switchAccount', {
+					...i,
+					token: token
+				}).then(() => {
+					this.$nextTick(() => {
+						location.reload();
+					});
+				});
+			});
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.nav-enter-active,
+.nav-leave-active {
+	opacity: 1;
+	transform: translateX(0);
+	transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.nav-enter,
+.nav-leave-active {
+	opacity: 0;
+	transform: translateX(-240px);
+}
+
+.nav-back-enter-active,
+.nav-back-leave-active {
+	opacity: 1;
+	transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.nav-back-enter,
+.nav-back-leave-active {
+	opacity: 0;
+}
+
+.mvcprjjd {
+	$ui-font-size: 1em; // TODO: どこかに集約したい
+	$nav-width: 250px; // TODO: どこかに集約したい
+	$nav-icon-only-width: 80px; // TODO: どこかに集約したい
+	$nav-icon-only-threshold: 1279px; // TODO: どこかに集約したい
+	$nav-hide-threshold: 650px; // TODO: どこかに集約したい
+
+	> .nav-back {
+		position: fixed;
+		top: 0;
+		left: 0;
+		z-index: 1001;
+		width: 100%;
+		height: 100%;
+		background: var(--modalBg);
+	}
+
+	> .nav {
+		$avatar-size: 32px;
+		$avatar-margin: 8px;
+
+		flex: 0 0 $nav-width;
+		width: $nav-width;
+		box-sizing: border-box;
+
+		@media (max-width: $nav-icon-only-threshold) {
+			flex: 0 0 $nav-icon-only-width;
+			width: $nav-icon-only-width;
+		}
+
+		@media (max-width: $nav-hide-threshold) {
+			position: fixed;
+			top: 0;
+			left: 0;
+			z-index: 1001;
+		}
+
+		@media (min-width: $nav-hide-threshold + 1px) {
+			display: block !important;
+		}
+
+		> div {
+			position: fixed;
+			top: 0;
+			left: 0;
+			z-index: 1001;
+			width: $nav-width;
+			height: 100vh;
+			box-sizing: border-box;
+			overflow: auto;
+			background: var(--navBg);
+			border-right: solid 1px var(--divider);
+
+			> .divider {
+				margin: 16px 0;
+				border-top: solid 1px var(--divider);
+			}
+
+			@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
+				width: $nav-icon-only-width;
+
+				> .divider {
+					margin: 8px auto;
+					width: calc(100% - 32px);
+				}
+
+				> .item {
+					&:first-child {
+						margin-bottom: 8px;
+					}
+
+					&:last-child {
+						margin-top: 8px;
+					}
+				}
+			}
+
+			> .item {
+				position: relative;
+				display: block;
+				padding-left: 32px;
+				font-size: $ui-font-size;
+				line-height: 3.2rem;
+				text-overflow: ellipsis;
+				overflow: hidden;
+				white-space: nowrap;
+				width: 100%;
+				text-align: left;
+				box-sizing: border-box;
+				color: var(--navFg);
+
+				> [data-icon] {
+					width: 32px;
+				}
+
+				> [data-icon],
+				> .avatar {
+					margin-right: $avatar-margin;
+				}
+
+				> .avatar {
+					width: $avatar-size;
+					height: $avatar-size;
+					vertical-align: middle;
+				}
+
+				> i {
+					position: absolute;
+					top: 0;
+					left: 20px;
+					color: var(--navIndicator);
+					font-size: 8px;
+					animation: blink 1s infinite;
+				}
+
+				&:hover {
+					text-decoration: none;
+					color: var(--navHoverFg);
+				}
+
+				&.active {
+					color: var(--navActive);
+				}
+
+				&:first-child, &:last-child {
+					position: sticky;
+					z-index: 1;
+					padding-top: 8px;
+					padding-bottom: 8px;
+					background: var(--X14);
+					-webkit-backdrop-filter: blur(8px);
+					backdrop-filter: blur(8px);
+				}
+
+				&:first-child {
+					top: 0;
+					margin-bottom: 16px;
+					border-bottom: solid 1px var(--divider);
+				}
+
+				&:last-child {
+					bottom: 0;
+					margin-top: 16px;
+					border-top: solid 1px var(--divider);
+				}
+
+				@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
+					padding-left: 0;
+					width: 100%;
+					text-align: center;
+					font-size: $ui-font-size * 1.2;
+					line-height: 3.7rem;
+
+					> [data-icon],
+					> .avatar {
+						margin-right: 0;
+					}
+
+					> i {
+						left: 10px;
+					}
+
+					> .text {
+						display: none;
+					}
+				}
+			}
+
+			@media (max-width: $nav-hide-threshold) {
+				> .index,
+				> .notifications {
+					display: none;
+				}
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue
index bd1901a62..ce0fd95ca 100644
--- a/src/client/components/timeline.vue
+++ b/src/client/components/timeline.vue
@@ -17,9 +17,11 @@ export default Vue.extend({
 			required: true
 		},
 		list: {
+			type: String,
 			required: false
 		},
 		antenna: {
+			type: String,
 			required: false
 		},
 		sound: {
@@ -53,6 +55,8 @@ export default Vue.extend({
 			const _note = JSON.parse(JSON.stringify(note));	// deepcopy
 			(this.$refs.tl as any).prepend(_note);
 
+			this.$emit('note');
+
 			if (this.sound) {
 				this.$root.sound(note.userId === this.$store.state.i.id ? 'noteMy' : 'note');
 			}
@@ -77,10 +81,10 @@ export default Vue.extend({
 		if (this.src == 'antenna') {
 			endpoint = 'antennas/notes';
 			this.query = {
-				antennaId: this.antenna.id
+				antennaId: this.antenna
 			};
 			this.connection = this.$root.stream.connectToChannel('antenna', {
-				antennaId: this.antenna.id
+				antennaId: this.antenna
 			});
 			this.connection.on('note', prepend);
 		} else if (this.src == 'home') {
@@ -106,10 +110,10 @@ export default Vue.extend({
 		} else if (this.src == 'list') {
 			endpoint = 'notes/user-list-timeline';
 			this.query = {
-				listId: this.list.id
+				listId: this.list
 			};
 			this.connection = this.$root.stream.connectToChannel('userList', {
-				listId: this.list.id
+				listId: this.list
 			});
 			this.connection.on('note', prepend);
 			this.connection.on('userAdded', onUserAdded);
diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue
index 3fed1f65c..6a718439a 100644
--- a/src/client/components/ui/container.vue
+++ b/src/client/components/ui/container.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader }">
+<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader, scrollable }" v-size="[{ max: 500 }]">
 	<header v-if="showHeader">
 		<div class="title"><slot name="header"></slot></div>
 		<slot name="func"></slot>
@@ -47,6 +47,11 @@ export default Vue.extend({
 			required: false,
 			default: true
 		},
+		scrollable: {
+			type: Boolean,
+			required: false,
+			default: false
+		},
 	},
 	data() {
 		return {
@@ -107,10 +112,19 @@ export default Vue.extend({
 		box-shadow: none !important;
 	}
 
+	&.scrollable {
+		display: flex;
+		flex-direction: column;
+
+		> div {
+			overflow: auto;
+		}
+	}
+
 	> header {
 		position: relative;
 		box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
-		z-index: 1;
+		z-index: 2;
 		background: var(--panelHeaderBg);
 		color: var(--panelHeaderFg);
 
@@ -118,10 +132,6 @@ export default Vue.extend({
 			margin: 0;
 			padding: 12px 16px;
 
-			@media (max-width: 500px) {
-				padding: 8px 10px;
-			}
-
 			> [data-icon] {
 				margin-right: 6px;
 			}
@@ -141,5 +151,21 @@ export default Vue.extend({
 			height: 100%;
 		}
 	}
+
+	&.max-width_500px {
+		> header {
+			> .title {
+				padding: 8px 10px;
+			}
+		}
+	}
+}
+
+._forceContainerFull_ .ukygtjoj {
+	> header {
+		> .title {
+			padding: 12px 16px !important;
+		}
+	}
 }
 </style>
diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue
index c9f62e3cc..d5317db7f 100644
--- a/src/client/components/ui/input.vue
+++ b/src/client/components/ui/input.vue
@@ -20,6 +20,7 @@
 				:pattern="pattern"
 				:autocomplete="autocomplete"
 				:spellcheck="spellcheck"
+				:step="step"
 				@focus="focused = true"
 				@blur="focused = false"
 				@keydown="$emit('keydown', $event)"
@@ -36,6 +37,7 @@
 				:pattern="pattern"
 				:autocomplete="autocomplete"
 				:spellcheck="spellcheck"
+				:step="step"
 				@focus="focused = true"
 				@blur="focused = false"
 				@keydown="$emit('keydown', $event)"
@@ -114,6 +116,9 @@ export default Vue.extend({
 		spellcheck: {
 			required: false
 		},
+		step: {
+			required: false
+		},
 		debounce: {
 			required: false
 		},
@@ -164,7 +169,7 @@ export default Vue.extend({
 		},
 		v(v) {
 			if (this.type === 'number') {
-				this.$emit('input', parseInt(v, 10));
+				this.$emit('input', parseFloat(v));
 			} else {
 				this.$emit('input', v);
 			}
@@ -297,7 +302,7 @@ export default Vue.extend({
 			pointer-events: none;
 			transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
 			transition-duration: 0.3s;
-			font-size: 16px;
+			font-size: 1em;
 			line-height: 32px;
 			color: var(--inputLabel);
 			pointer-events: none;
@@ -312,7 +317,7 @@ export default Vue.extend({
 			top: -17px;
 			left: 0 !important;
 			pointer-events: none;
-			font-size: 16px;
+			font-size: 1em;
 			line-height: 32px;
 			color: var(--inputLabel);
 			pointer-events: none;
@@ -343,7 +348,7 @@ export default Vue.extend({
 			padding: 0;
 			font: inherit;
 			font-weight: normal;
-			font-size: 16px;
+			font-size: 1em;
 			line-height: $height;
 			color: var(--inputText);
 			background: transparent;
@@ -364,7 +369,7 @@ export default Vue.extend({
 			position: absolute;
 			z-index: 1;
 			top: 0;
-			font-size: 16px;
+			font-size: 1em;
 			line-height: 32px;
 			color: var(--inputLabel);
 			pointer-events: none;
diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue
index ce2194971..55f76553a 100644
--- a/src/client/components/ui/select.vue
+++ b/src/client/components/ui/select.vue
@@ -135,7 +135,7 @@ export default Vue.extend({
 			pointer-events: none;
 			transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
 			transition-duration: 0.3s;
-			font-size: 16px;
+			font-size: 1em;
 			line-height: 32px;
 			pointer-events: none;
 			//will-change transform
@@ -150,7 +150,7 @@ export default Vue.extend({
 			padding: 0;
 			font: inherit;
 			font-weight: normal;
-			font-size: 16px;
+			font-size: 1em;
 			height: 32px;
 			background: none;
 			border: none;
@@ -170,7 +170,7 @@ export default Vue.extend({
 			display: block;
 			align-self: center;
 			justify-self: center;
-			font-size: 16px;
+			font-size: 1em;
 			line-height: 32px;
 			color: rgba(#000, 0.54);
 			pointer-events: none;
diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue
index 18a2ec33f..9652a0102 100644
--- a/src/client/components/ui/switch.vue
+++ b/src/client/components/ui/switch.vue
@@ -5,7 +5,7 @@
 	role="switch"
 	:aria-checked="checked"
 	:aria-disabled="disabled"
-	@click="toggle"
+	@click.prevent="toggle"
 >
 	<input
 		type="checkbox"
diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue
index fab307a20..a42813ee6 100644
--- a/src/client/components/ui/textarea.vue
+++ b/src/client/components/ui/textarea.vue
@@ -133,7 +133,7 @@ export default Vue.extend({
 			pointer-events: none;
 			transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
 			transition-duration: 0.3s;
-			font-size: 16px;
+			font-size: 1em;
 			line-height: 32px;
 			pointer-events: none;
 			//will-change transform
@@ -151,7 +151,7 @@ export default Vue.extend({
 			box-sizing: border-box;
 			font: inherit;
 			font-weight: normal;
-			font-size: 16px;
+			font-size: 1em;
 			background: transparent;
 			border: none;
 			border-radius: 0;
diff --git a/src/client/components/window.vue b/src/client/components/window.vue
index db1398518..a0bff869b 100644
--- a/src/client/components/window.vue
+++ b/src/client/components/window.vue
@@ -1,5 +1,5 @@
 <template>
-<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }">
+<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }" :can-close="canClose">
 	<div class="ebkgoccj" :class="{ noPadding }" @keydown="onKeydown" :style="{ width: `${width}px`, height: `${height}px` }">
 		<div class="header">
 			<button class="_button" v-if="withOkButton" @click="close()"><fa :icon="faTimes"/></button>
@@ -57,6 +57,11 @@ export default Vue.extend({
 			required: false,
 			default: 400
 		},
+		canClose: {
+			type: Boolean,
+			required: false,
+			default: true,
+		},
 	},
 
 	data() {
diff --git a/src/client/config.ts b/src/client/config.ts
index b9a476618..badb69524 100644
--- a/src/client/config.ts
+++ b/src/client/config.ts
@@ -18,3 +18,4 @@ export const getLocale = async () => Object.fromEntries((await entries(clientDb.
 export const version = _VERSION_;
 export const env = _ENV_;
 export const instanceName = siteName === 'Misskey' ? null : siteName;
+export const deckmode = localStorage.getItem('deckmode') === 'true';
diff --git a/src/client/deck.vue b/src/client/deck.vue
new file mode 100644
index 000000000..669719ba8
--- /dev/null
+++ b/src/client/deck.vue
@@ -0,0 +1,312 @@
+<template>
+<div class="mk-deck" :class="`${$store.state.device.deckColumnAlign}`" v-hotkey.global="keymap">
+	<x-sidebar ref="nav"/>
+
+	<!-- TODO: deckMainColumnPlace を見て位置変える -->
+	<deck-column class="column" v-if="$store.state.device.deckAlwaysShowMainColumn || $route.name !== 'index'">
+		<template #action>
+			<button class="_button back" v-if="canBack" @click="back()"><fa :icon="faChevronLeft"/></button>
+		</template>
+
+		<template #header>
+			<div class="iwnjqeul">
+				<div class="default">
+					<portal-target name="avatar" slim/>
+					<span class="title"><portal-target name="icon" slim/><portal-target name="title" slim/></span>
+				</div>
+				<div class="custom">
+					<portal-target name="header" slim/>
+				</div>
+			</div>
+		</template>
+
+		<router-view></router-view>
+	</deck-column>
+
+	<template v-for="ids in layout">
+		<div v-if="ids.length > 1" class="folder column">
+			<deck-column-core v-for="id, i in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
+		</div>
+		<deck-column-core v-else class="column" :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id === ids[0])" @parent-focus="moveFocus(ids[0], $event)"/>
+	</template>
+
+	<button @click="addColumn" class="_button add"><fa :icon="faPlus"/></button>
+
+	<button v-if="$store.getters.isSignedIn" class="nav _button" @click="showNav()"><fa :icon="faBars"/><i v-if="navIndicated"><fa :icon="faCircle"/></i></button>
+	<button v-if="$store.getters.isSignedIn" class="post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button>
+
+	<stream-indicator v-if="$store.getters.isSignedIn"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus, faPencilAlt, faChevronLeft, faBars, faCircle } from '@fortawesome/free-solid-svg-icons';
+import {  } from '@fortawesome/free-regular-svg-icons';
+import { v4 as uuid } from 'uuid';
+import { host } from './config';
+import { search } from './scripts/search';
+import DeckColumnCore from './components/deck/column-core.vue';
+import DeckColumn from './components/deck/column.vue';
+import XSidebar from './components/sidebar.vue';
+
+export default Vue.extend({
+	components: {
+		XSidebar,
+		DeckColumn,
+		DeckColumnCore,
+	},
+
+	data() {
+		return {
+			host: host,
+			pageKey: 0,
+			searching: false,
+			connection: null,
+			searchQuery: '',
+			searchWait: false,
+			canBack: false,
+			menuDef: this.$store.getters.nav({}),
+			wallpaper: localStorage.getItem('wallpaper') != null,
+			faPlus, faPencilAlt, faChevronLeft, faBars, faCircle
+		};
+	},
+
+	computed: {
+		deck() {
+			return this.$store.state.deviceUser.deck;
+		},
+		columns(): any[] {
+			return this.deck.columns;
+		},
+		layout(): any[] {
+			return this.deck.layout;
+		},
+		navIndicated(): boolean {
+			if (!this.$store.getters.isSignedIn) return false;
+			for (const def in this.menuDef) {
+				if (this.menuDef[def].indicated) return true;
+			}
+			return false;
+		},
+		keymap(): any {
+			return {
+				'p': this.post,
+				'n': this.post,
+				's': this.search,
+				'h|/': this.help
+			};
+		},
+	},
+
+	watch: {
+		$route(to, from) {
+			this.pageKey++;
+			this.canBack = (window.history.length > 0 && !['index'].includes(to.name));
+		},
+	},
+
+	created() {
+		document.documentElement.style.overflowY = 'hidden';
+
+		if (this.$store.getters.isSignedIn) {
+			this.connection = this.$root.stream.useSharedConnection('main');
+			this.connection.on('notification', this.onNotification);
+		}
+	},
+
+	mounted() {
+	},
+
+	methods: {
+		showNav() {
+			this.$refs.nav.show();
+		},
+
+		help() {
+			this.$router.push('/docs/keyboard-shortcut');
+		},
+
+		back() {
+			if (this.canBack) window.history.back();
+		},
+
+		post() {
+			this.$root.post();
+		},
+
+		search() {
+			if (this.searching) return;
+
+			this.$root.dialog({
+				title: this.$t('search'),
+				input: true
+			}).then(async ({ canceled, result: query }) => {
+				if (canceled || query == null || query === '') return;
+
+				this.searching = true;
+				search(this, query).finally(() => {
+					this.searching = false;
+				});
+			});
+		},
+
+		async onNotification(notification) {
+			if (document.visibilityState === 'visible') {
+				this.$root.stream.send('readNotification', {
+					id: notification.id
+				});
+
+				this.$root.new(await import('./components/toast.vue').then(m => m.default), {
+					notification
+				});
+			}
+
+			this.$root.sound('notification');
+		},
+
+		async addColumn(ev) {
+			const columns = [
+				'widgets',
+				'notifications',
+				'tl',
+				'antenna',
+				'list',
+				'mentions',
+				'direct',
+			];
+
+			const { canceled, result: column } = await this.$root.dialog({
+				title: this.$t('_deck.addColumn'),
+				type: null,
+				select: {
+					items: columns.map(column => ({
+						value: column, text: this.$t('_deck._columns.' + column)
+					}))
+				},
+				showCancelButton: true
+			});
+			if (canceled) return;
+
+			this.$store.commit('deviceUser/addDeckColumn', {
+				type: column,
+				id: uuid(),
+				name: this.$t('_deck._columns.' + column),
+				width: 330,
+			});
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-deck {
+	$nav-hide-threshold: 650px; // TODO: どこかに集約したい
+
+	// TODO: この値を設定で変えられるようにする?
+	$columnMargin: 12px;
+
+	$deckMargin: 12px;
+
+	--margin: var(--marginHalf);
+
+	display: flex;
+	height: 100vh;
+	box-sizing: border-box;
+	flex: 1;
+	padding: $deckMargin 0 $deckMargin $deckMargin;
+
+	&.center {
+		> .column:first-of-type {
+			margin-left: auto;
+		}
+
+		> .add {
+			margin-right: auto;
+		}
+	}
+
+	> .column {
+		flex-shrink: 0;
+		margin-right: $columnMargin;
+
+		&.folder {
+			display: flex;
+			flex-direction: column;
+
+			> *:not(:last-child) {
+				margin-bottom: $columnMargin;
+			}
+		}
+	}
+
+	> .post,
+	> .nav {
+		position: fixed;
+		z-index: 1000;
+		bottom: 32px;
+		width: 64px;
+		height: 64px;
+		border-radius: 100%;
+		box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12);
+		font-size: 22px;
+	}
+
+	> .post {
+		right: 32px;
+	}
+
+	> .nav {
+		left: 32px;
+		background: var(--panel);
+		color: var(--fg);
+
+		@media (min-width: ($nav-hide-threshold + 1px)) {
+			display: none;
+		}
+
+		&:hover {
+			background: var(--X2);
+		}
+
+		> i {
+			position: absolute;
+			top: 0;
+			left: 0;
+			color: var(--indicator);
+			font-size: 16px;
+			animation: blink 1s infinite;
+		}
+	}
+}
+
+.iwnjqeul {
+	$header-height: 42px; // TODO: column.vueのそれを参照するようにしたい(出来るのか?)
+
+	> .default {
+		> .avatar {
+			$size: 28px;
+			display: inline-block;
+			width: $size;
+			height: $size;
+			vertical-align: bottom;
+			margin: (($header-height - $size) / 2) 8px (($header-height - $size) / 2) 0;
+		}
+
+		> .title {
+			display: inline-block;
+			margin: 0;
+			line-height: $header-height;
+
+			> [data-icon] {
+				margin-right: 8px;
+			}
+		}
+	}
+
+	> .custom {
+		position: absolute;
+		top: 0;
+	}
+}
+</style>
diff --git a/src/client/init.ts b/src/client/init.ts
index 21f233cc9..d00b4f5cc 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -1,5 +1,5 @@
 /**
- * App entry point
+ * Client entry point
  */
 
 import Vue from 'vue';
@@ -12,11 +12,13 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
 
 import VueHotkey from './scripts/hotkey';
 import App from './app.vue';
+import Deck from './deck.vue';
 import MiOS from './mios';
-import { version, langs, instanceName, getLocale } from './config';
+import { version, langs, instanceName, getLocale, deckmode } from './config';
 import PostFormDialog from './components/post-form-dialog.vue';
 import Dialog from './components/dialog.vue';
 import Menu from './components/menu.vue';
+import Form from './components/form-window.vue';
 import { router } from './router';
 import { applyTheme, lightTheme } from './scripts/theme';
 import { isDeviceDarkmode } from './scripts/is-device-darkmode';
@@ -165,6 +167,7 @@ os.init(async () => {
 				i18n // TODO: 消せないか考える SEE: https://github.com/syuilo/misskey/pull/6396#discussion_r429511030
 			};
 		},
+		// TODO: ここらへんのメソッド全部Vuexに移したい
 		methods: {
 			api: (endpoint: string, data: { [x: string]: any } = {}, token?) => store.dispatch('api', { endpoint, data, token }),
 			signout: os.signout,
@@ -194,6 +197,13 @@ os.init(async () => {
 				});
 				return p;
 			},
+			form(title, form) {
+				const vm = this.new(Form, { title, form });
+				return new Promise((res) => {
+					vm.$once('ok', result => res({ canceled: false, result }));
+					vm.$once('cancel', () => res({ canceled: true }));
+				});
+			},
 			post(opts, cb) {
 				if (!this.$store.getters.isSignedIn) return;
 				const vm = this.new(PostFormDialog, opts);
@@ -210,11 +220,9 @@ os.init(async () => {
 			}
 		},
 		router: router,
-		render: createEl => createEl(App)
+		render: createEl => createEl(deckmode ? Deck : App)
 	});
 
-	os.app = app;
-
 	// マウント
 	app.$mount('#app');
 
diff --git a/src/client/mios.ts b/src/client/mios.ts
index c54b6fff8..efeb630d7 100644
--- a/src/client/mios.ts
+++ b/src/client/mios.ts
@@ -1,7 +1,6 @@
 // TODO: このファイル消したい
 
 import autobind from 'autobind-decorator';
-import Vue from 'vue';
 import { EventEmitter } from 'eventemitter3';
 
 import { apiUrl, version } from './config';
@@ -14,8 +13,6 @@ import store from './store';
  * Misskey Operating System
  */
 export default class MiOS extends EventEmitter {
-	public app: Vue;
-
 	public store: ReturnType<typeof store>;
 
 	/**
diff --git a/src/client/pages/index.home.vue b/src/client/pages/index.home.vue
index 17d07e608..2059b34ac 100644
--- a/src/client/pages/index.home.vue
+++ b/src/client/pages/index.home.vue
@@ -19,7 +19,7 @@
 	<x-tutorial class="tutorial" v-if="$store.state.settings.tutorial != -1"/>
 
 	<x-post-form class="post-form _panel" fixed v-if="$store.state.device.showFixedPostForm"/>
-	<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list" :antenna="antenna" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
+	<x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list ? list.id : null" :antenna="antenna ? antenna.id : null" :sound="true" @before="before()" @after="after()" @queue="queueUpdated"/>
 </div>
 </template>
 
diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue
index 48629a4eb..5464875df 100644
--- a/src/client/pages/note.vue
+++ b/src/client/pages/note.vue
@@ -15,14 +15,15 @@
 
 		<mk-remote-caution v-if="note.user.host != null" :href="note.url || note.uri" style="margin-bottom: var(--margin)"/>
 		<x-note :note="note" :key="note.id" :detail="true"/>
-		<div v-if="error">
-			<mk-error @retry="fetch()"/>
-		</div>
 
 		<button class="_panel _button" v-if="hasPrev && !showPrev" @click="showPrev = true" style="margin: var(--margin) auto 0 auto;"><fa :icon="faChevronDown"/></button>
 		<hr v-if="showPrev"/>
 		<x-notes v-if="showPrev" ref="prev" :pagination="prev" style="margin-top: var(--margin);"/>
 	</div>
+
+	<div v-if="error">
+		<mk-error @retry="fetch()"/>
+	</div>
 </div>
 </template>
 
diff --git a/src/client/pages/preferences/index.vue b/src/client/pages/preferences/index.vue
index 92d745a84..ffc885876 100644
--- a/src/client/pages/preferences/index.vue
+++ b/src/client/pages/preferences/index.vue
@@ -51,6 +51,20 @@
 		</div>
 	</section>
 
+	<section class="_card">
+		<div class="_title"><fa :icon="faColumns"/> {{ $t('deck') }}</div>
+		<div class="_content">
+			<mk-switch v-model="deckAlwaysShowMainColumn">
+				{{ $t('_deck.alwaysShowMainColumn') }}
+			</mk-switch>
+		</div>
+		<div class="_content">
+			<div>{{ $t('_deck.columnAlign') }}</div>
+			<mk-radio v-model="deckColumnAlign" value="left">{{ $t('left') }}</mk-radio>
+			<mk-radio v-model="deckColumnAlign" value="center">{{ $t('center') }}</mk-radio>
+		</div>
+	</section>
+
 	<section class="_card">
 		<div class="_title"><fa :icon="faCog"/> {{ $t('accessibility') }}</div>
 		<div class="_content">
@@ -93,7 +107,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons';
+import { faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, 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';
@@ -145,7 +159,7 @@ export default Vue.extend({
 			lang: localStorage.getItem('lang'),
 			fontSize: localStorage.getItem('fontSize'),
 			sounds,
-			faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute
+			faImage, faCog, faMusic, faPlay, faVolumeUp, faVolumeMute, faColumns
 		}
 	},
 
@@ -195,6 +209,16 @@ export default Vue.extend({
 			set(value) { this.$store.commit('device/set', { key: 'fixedWidgetsPosition', 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 }); }
+		},
+
 		sfxVolume: {
 			get() { return this.$store.state.device.sfxVolume; },
 			set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); }
diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue
index 1878a9b1f..f03c4adf8 100644
--- a/src/client/pages/user/index.timeline.vue
+++ b/src/client/pages/user/index.timeline.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="kjeftjfm">
+<div class="kjeftjfm" v-size="[{ max: 500 }]">
 	<div class="with">
 		<button class="_button" @click="with_ = null" :class="{ active: with_ === null }">{{ $t('notes') }}</button>
 		<button class="_button" @click="with_ = 'replies'" :class="{ active: with_ === 'replies' }">{{ $t('notesAndReplies') }}</button>
@@ -60,10 +60,6 @@ export default Vue.extend({
 		display: flex;
 		margin-bottom: var(--margin);
 
-		@media (max-width: 500px) {
-			font-size: 80%;
-		}
-
 		> button {
 			flex: 1;
 			padding: 11px 8px 8px 8px;
@@ -75,5 +71,11 @@ export default Vue.extend({
 			}
 		}
 	}
+
+	&.max-width_500px {
+		> .with {
+			font-size: 80%;
+		}
+	}
 }
 </style>
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
index 75f61a0c0..20eaca368 100644
--- a/src/client/pages/user/index.vue
+++ b/src/client/pages/user/index.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="mk-user-page" v-if="user">
+<div class="mk-user-page" v-if="user" v-size="[{ max: 500 }]">
 	<portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
 	<portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
 
@@ -118,6 +118,7 @@ import MkContainer from '../../components/ui/container.vue';
 import MkRemoteCaution from '../../components/remote-caution.vue';
 import Progress from '../../scripts/loading';
 import parseAcct from '../../../misc/acct/parse';
+import { getScrollPosition } from '../../scripts/scroll';
 
 export default Vue.extend({
 	components: {
@@ -168,12 +169,8 @@ export default Vue.extend({
 
 	mounted() {
 		window.requestAnimationFrame(this.parallaxLoop);
-		window.addEventListener('scroll', this.parallax, { passive: true });
-		document.addEventListener('touchmove', this.parallax, { passive: true });
 		this.$once('hook:beforeDestroy', () => {
 			window.cancelAnimationFrame(this.parallaxAnimationId);
-			window.removeEventListener('scroll', this.parallax);
-			document.removeEventListener('touchmove', this.parallax);
 		});
 	},
 
@@ -205,7 +202,7 @@ export default Vue.extend({
 			const banner = this.$refs.banner as any;
 			if (banner == null) return;
 
-			const top = window.scrollY;
+			const top = getScrollPosition(this.$el);
 
 			if (top < 0) return;
 
@@ -219,7 +216,6 @@ export default Vue.extend({
 
 <style lang="scss" scoped>
 .mk-user-page {
-
 	> .punished {
 		font-size: 0.8em;
 		padding: 16px;
@@ -237,10 +233,6 @@ export default Vue.extend({
 			background-size: cover;
 			background-position: center;
 
-			@media (max-width: 500px) {
-				height: 140px;
-			}
-
 			> .banner {
 				height: 100%;
 				background-color: #4c5e6d;
@@ -257,10 +249,6 @@ export default Vue.extend({
 				width: 100%;
 				height: 78px;
 				background: linear-gradient(transparent, rgba(#000, 0.7));
-
-				@media (max-width: 500px) {
-					display: none;
-				}
 			}
 
 			> .followed {
@@ -308,10 +296,6 @@ export default Vue.extend({
 				box-sizing: border-box;
 				color: #fff;
 
-				@media (max-width: 500px) {
-					display: none;
-				}
-
 				> .name {
 					display: block;
 					margin: 0;
@@ -343,10 +327,6 @@ export default Vue.extend({
 			font-weight: bold;
 			border-bottom: solid 1px var(--divider);
 
-			@media (max-width: 500px) {
-				display: block;
-			}
-
 			> .bottom {
 				> * {
 					display: inline-block;
@@ -365,26 +345,12 @@ export default Vue.extend({
 			width: 120px;
 			height: 120px;
 			box-shadow: 1px 1px 3px rgba(#000, 0.2);
-
-			@media (max-width: 500px) {
-				top: 90px;
-				left: 0;
-				right: 0;
-				width: 92px;
-				height: 92px;
-				margin: auto;
-			}
 		}
 
 		> .description {
 			padding: 24px 24px 24px 154px;
 			font-size: 0.95em;
 
-			@media (max-width: 500px) {
-				padding: 16px;
-				text-align: center;
-			}
-
 			> .empty {
 				margin: 0;
 				opacity: 0.5;
@@ -396,10 +362,6 @@ export default Vue.extend({
 			font-size: 0.9em;
 			border-top: solid 1px var(--divider);
 
-			@media (max-width: 500px) {
-				padding: 16px;
-			}
-		
 			> .field {
 				display: flex;
 				padding: 0;
@@ -436,10 +398,6 @@ export default Vue.extend({
 			padding: 24px;
 			border-top: solid 1px var(--divider);
 
-			@media (max-width: 500px) {
-				padding: 16px;
-			}
-
 			> a {
 				flex: 1;
 				text-align: center;
@@ -473,5 +431,47 @@ export default Vue.extend({
 	> .content {
 		margin-bottom: var(--margin);
 	}
+
+	&.max-width_500px {
+		> .profile {
+			> .banner-container {
+				height: 140px;
+
+				> .fade {
+					display: none;
+				}
+
+				> .title {
+					display: none;
+				}
+			}
+
+			> .title {
+				display: block;
+			}
+
+			> .avatar {
+				top: 90px;
+				left: 0;
+				right: 0;
+				width: 92px;
+				height: 92px;
+				margin: auto;
+			}
+
+			> .description {
+				padding: 16px;
+				text-align: center;
+			}
+
+			> .fields {
+				padding: 16px;
+			}
+
+			> .status {
+				padding: 16px;
+			}
+		}
+	}
 }
 </style>
diff --git a/src/client/scripts/form.ts b/src/client/scripts/form.ts
new file mode 100644
index 000000000..3cf062be2
--- /dev/null
+++ b/src/client/scripts/form.ts
@@ -0,0 +1,26 @@
+export type FormItem = {
+	label?: string;
+	type: 'string';
+	default: string | null;
+	hidden?: boolean;
+	multiline?: boolean;
+} | {
+	label?: string;
+	type: 'number';
+	default: number | null;
+	hidden?: boolean;
+	step?: number;
+} | {
+	label?: string;
+	type: 'boolean';
+	default: boolean | null;
+	hidden?: boolean;
+} | {
+	label?: string;
+	type: 'enum';
+	default: string | null;
+	hidden?: boolean;
+	enum: string[];
+};
+
+export type Form = Record<string, FormItem>;
diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts
index 1f302753e..8efff7aa4 100644
--- a/src/client/scripts/paging.ts
+++ b/src/client/scripts/paging.ts
@@ -13,7 +13,7 @@ export default (opts) => ({
 			moreFetching: false,
 			inited: false,
 			more: false,
-			backed: false,
+			backed: false, // 遡り中か否か
 			isBackTop: false,
 			ilObserver: new IntersectionObserver(
 				(entries) => entries.some((entry) => entry.isIntersecting)
diff --git a/src/client/scripts/scroll.ts b/src/client/scripts/scroll.ts
index 76881bbde..f32e50cdc 100644
--- a/src/client/scripts/scroll.ts
+++ b/src/client/scripts/scroll.ts
@@ -1,7 +1,7 @@
 export function getScrollContainer(el: Element | null): Element | null {
 	if (el == null || el.tagName === 'BODY') return null;
-	const style = window.getComputedStyle(el);
-	if (style.getPropertyValue('overflow') === 'auto') {
+	const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
+	if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる
 		return el;
 	} else {
 		return getScrollContainer(el.parentElement);
diff --git a/src/client/store.ts b/src/client/store.ts
index eee3f5961..5eff0567a 100644
--- a/src/client/store.ts
+++ b/src/client/store.ts
@@ -1,9 +1,10 @@
 import Vuex from 'vuex';
 import createPersistedState from 'vuex-persistedstate';
 import * as nestedProperty from 'nested-property';
-import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed } from '@fortawesome/free-solid-svg-icons';
+import { faTerminal, faHashtag, faBroadcastTower, faFireAlt, faSearch, faStar, faAt, faListUl, faUserClock, faUsers, faCloud, faGamepad, faFileAlt, faSatellite, faDoorClosed, faColumns } from '@fortawesome/free-solid-svg-icons';
 import { faBell, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
-import { apiUrl } from './config';
+import { apiUrl, deckmode } from './config';
+import { erase } from '../prelude/array';
 
 export const defaultSettings = {
 	tutorial: 0,
@@ -35,7 +36,13 @@ export const defaultDeviceUserSettings = {
 		'explore',
 		'announcements',
 		'search',
+		'-',
+		'deck',
 	],
+	deck: {
+		columns: [],
+		layout: [],
+	},
 };
 
 export const defaultDeviceSettings = {
@@ -50,6 +57,7 @@ export const defaultDeviceSettings = {
 	darkTheme: '8c539dc1-0fab-4d47-9194-39c508e9bfe1',
 	lightTheme: '4eea646f-7afa-4645-83e9-83af0333cd37',
 	darkMode: false,
+	deckMode: false,
 	syncDeviceDarkMode: true,
 	animation: true,
 	animatedMfm: true,
@@ -60,6 +68,9 @@ export const defaultDeviceSettings = {
 	fixedWidgetsPosition: false,
 	roomGraphicsQuality: 'medium',
 	roomUseOrthographicCamera: true,
+	deckColumnAlign: 'left',
+	deckAlwaysShowMainColumn: true,
+	deckMainColumnPlace: 'left',
 	sfxVolume: 0.3,
 	sfxNote: 'syuilo/down',
 	sfxNoteMy: 'syuilo/up',
@@ -197,6 +208,14 @@ export default () => new Vuex.Store({
 				get show() { return getters.isSignedIn; },
 				get to() { return `/@${state.i.username}/room`; },
 			},
+			deck: {
+				title: deckmode ? 'undeck' : 'deck',
+				icon: faColumns,
+				action: () => {
+					localStorage.setItem('deckmode', (!deckmode).toString());
+					location.reload();
+				},
+			},
 		}),
 	},
 
@@ -399,6 +418,137 @@ export default () => new Vuex.Store({
 						w.data = x.data;
 					}
 				},
+
+				//#region Deck
+				addDeckColumn(state, column) {
+					if (column.name == undefined) column.name = null;
+					state.deck.columns.push(column);
+					state.deck.layout.push([column.id]);
+				},
+		
+				removeDeckColumn(state, id) {
+					state.deck.columns = state.deck.columns.filter(c => c.id != id);
+					state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
+					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
+				},
+		
+				swapDeckColumn(state, x) {
+					const a = x.a;
+					const b = x.b;
+					const aX = state.deck.layout.findIndex(ids => ids.indexOf(a) != -1);
+					const aY = state.deck.layout[aX].findIndex(id => id == a);
+					const bX = state.deck.layout.findIndex(ids => ids.indexOf(b) != -1);
+					const bY = state.deck.layout[bX].findIndex(id => id == b);
+					state.deck.layout[aX][aY] = b;
+					state.deck.layout[bX][bY] = a;
+				},
+		
+				swapLeftDeckColumn(state, id) {
+					state.deck.layout.some((ids, i) => {
+						if (ids.indexOf(id) != -1) {
+							const left = state.deck.layout[i - 1];
+							if (left) {
+								// https://vuejs.org/v2/guide/list.html#Caveats
+								//state.deck.layout[i - 1] = state.deck.layout[i];
+								//state.deck.layout[i] = left;
+								state.deck.layout.splice(i - 1, 1, state.deck.layout[i]);
+								state.deck.layout.splice(i, 1, left);
+							}
+							return true;
+						}
+					});
+				},
+		
+				swapRightDeckColumn(state, id) {
+					state.deck.layout.some((ids, i) => {
+						if (ids.indexOf(id) != -1) {
+							const right = state.deck.layout[i + 1];
+							if (right) {
+								// https://vuejs.org/v2/guide/list.html#Caveats
+								//state.deck.layout[i + 1] = state.deck.layout[i];
+								//state.deck.layout[i] = right;
+								state.deck.layout.splice(i + 1, 1, state.deck.layout[i]);
+								state.deck.layout.splice(i, 1, right);
+							}
+							return true;
+						}
+					});
+				},
+		
+				swapUpDeckColumn(state, id) {
+					const ids = state.deck.layout.find(ids => ids.indexOf(id) != -1);
+					ids.some((x, i) => {
+						if (x == id) {
+							const up = ids[i - 1];
+							if (up) {
+								// https://vuejs.org/v2/guide/list.html#Caveats
+								//ids[i - 1] = id;
+								//ids[i] = up;
+								ids.splice(i - 1, 1, id);
+								ids.splice(i, 1, up);
+							}
+							return true;
+						}
+					});
+				},
+		
+				swapDownDeckColumn(state, id) {
+					const ids = state.deck.layout.find(ids => ids.indexOf(id) != -1);
+					ids.some((x, i) => {
+						if (x == id) {
+							const down = ids[i + 1];
+							if (down) {
+								// https://vuejs.org/v2/guide/list.html#Caveats
+								//ids[i + 1] = id;
+								//ids[i] = down;
+								ids.splice(i + 1, 1, id);
+								ids.splice(i, 1, down);
+							}
+							return true;
+						}
+					});
+				},
+		
+				stackLeftDeckColumn(state, id) {
+					const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
+					state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
+					const left = state.deck.layout[i - 1];
+					if (left) state.deck.layout[i - 1].push(id);
+					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
+				},
+		
+				popRightDeckColumn(state, id) {
+					const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
+					state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
+					state.deck.layout.splice(i + 1, 0, [id]);
+					state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
+				},
+		
+				addDeckWidget(state, x) {
+					const column = state.deck.columns.find(c => c.id == x.id);
+					if (column == null) return;
+					if (column.widgets == null) column.widgets = [];
+					column.widgets.unshift(x.widget);
+				},
+		
+				removeDeckWidget(state, x) {
+					const column = state.deck.columns.find(c => c.id == x.id);
+					if (column == null) return;
+					column.widgets = column.widgets.filter(w => w.id != x.widget.id);
+				},
+		
+				renameDeckColumn(state, x) {
+					const column = state.deck.columns.find(c => c.id == x.id);
+					if (column == null) return;
+					column.name = x.name;
+				},
+		
+				updateDeckColumn(state, x) {
+					let column = state.deck.columns.find(c => c.id == x.id);
+					if (column == null) return;
+					column = x;
+				},
+				//#endregion
 			}
 		},
 
diff --git a/src/client/style.scss b/src/client/style.scss
index 3faecee43..cc650ab12 100644
--- a/src/client/style.scss
+++ b/src/client/style.scss
@@ -3,7 +3,7 @@
 :root {
 	--radius: 8px;
 	--marginFull: 16px;
-	--marginHalf: 8px;
+	--marginHalf: 10px;
 
 	--margin: var(--marginFull);
 
@@ -25,7 +25,6 @@ html {
 	background-position: center;
 	color: var(--fg);
 	overflow: auto;
-	overflow-y: scroll;
 
 	&, * {
 		scrollbar-color: var(--scrollbarHandle) var(--panel);
@@ -278,13 +277,14 @@ hr {
 
 ._panel {
 	position: relative;
+	z-index: 1;
 	background: var(--panel);
 	border-radius: var(--radius);
 	box-shadow: 0 0 0 1px var(--panelBorder);
 	overflow: hidden;
 }
 
-._widget ._list_ ._panel {
+._close_ ._list_ > * {
 	box-shadow: 0 1px 0 0 var(--divider), 0 -1px 0 0 var(--divider);
 	border-radius: 0;
 	margin: 0 !important;
@@ -348,31 +348,6 @@ hr {
 		& + ._content {
 			border-top: solid 1px var(--divider);
 		}
-
-		&._list {
-			padding: 16px;
-
-			@media (max-width: 500px) {
-				padding: 8px;
-			}
-
-			._listItem {
-				padding: 8px 16px;
-				border-radius: var(--radius);
-
-				@media (max-width: 500px) {
-					padding: 8px;
-				}
-
-				&:hover {
-					background: var(--listItemHoverBg);
-				}
-
-				> * {
-					pointer-events: none;
-				}
-			}
-		}
 	}
 
 	> ._footer {
@@ -385,6 +360,21 @@ hr {
 	}
 }
 
+._narrow_ ._card {
+	> ._title {
+		padding: 16px;
+		font-size: 1em;
+	}
+
+	> ._content {
+		padding: 16px;
+	}
+
+	> ._footer {
+		padding: 16px;
+	}
+}
+
 ._fullinfo {
 	padding: 64px 32px;
 	text-align: center;
diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5
index 9b8012860..4e5225db3 100644
--- a/src/client/themes/_dark.json5
+++ b/src/client/themes/_dark.json5
@@ -26,8 +26,8 @@
 		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
 		panelBorder: 'rgba(0, 0, 0, 0)',
 		shadow: 'rgba(0, 0, 0, 0.1)',
-		header: 'rgba(20, 20, 20, 0.75)',
-		navBg: '@panel',
+		header: ':alpha<0.7<@bg',
+		navBg: '@bg',
 		navFg: '@fg',
 		navHoverFg: ':lighten<17<@fg',
 		navActive: '@accent',
@@ -58,6 +58,7 @@
 		wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
 		badge: '#31b1ce',
 		messageBg: ':lighten<5<@bg',
+		deckColumnBorder: ':lighten<10<@panel',
 		X1: ':alpha<0<@bg',
 		X2: ':darken<2<@panel',
 		X3: 'rgba(255, 255, 255, 0.05)',
diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5
index e0b6d3cd6..2317ddef6 100644
--- a/src/client/themes/_light.json5
+++ b/src/client/themes/_light.json5
@@ -26,8 +26,8 @@
 		panelHeaderDivider: 'rgba(0, 0, 0, 0)',
 		panelBorder: 'rgba(0, 0, 0, 0)',
 		shadow: 'rgba(0, 0, 0, 0.1)',
-		header: 'rgba(255, 255, 255, 0.75)',
-		navBg: '@panel',
+		header: ':alpha<0.7<@bg',
+		navBg: '@bg',
 		navFg: '@fg',
 		navHoverFg: ':darken<17<@fg',
 		navActive: '@accent',
@@ -58,6 +58,7 @@
 		wallpaperOverlay: 'rgba(255, 255, 255, 0.5)',
 		badge: '#31b1ce',
 		messageBg: '@panel',
+		deckColumnBorder: ':darken<20<@panel',
 		X1: ':alpha<0<@bg',
 		X2: ':darken<2<@panel',
 		X3: 'rgba(0, 0, 0, 0.05)',
diff --git a/src/client/themes/black.json5 b/src/client/themes/black.json5
index 33a9050f6..3504f1593 100644
--- a/src/client/themes/black.json5
+++ b/src/client/themes/black.json5
@@ -13,5 +13,6 @@
 		panelHeaderDivider: '@divider',
 		panelBorder: '@divider',
 		messageBg: '#1d1d1d',
+		deckColumnBorder: '@divider',
 	},
 }
diff --git a/src/client/themes/lilac.json5 b/src/client/themes/lilac.json5
index 44e259151..084f3fc40 100644
--- a/src/client/themes/lilac.json5
+++ b/src/client/themes/lilac.json5
@@ -10,9 +10,11 @@
 		accent: 'rgb(206, 147, 191)',
 		bg: 'rgb(253, 242, 243)',
 		fg: 'rgb(161, 139, 146)',
+		divider: '#ece7e7',
 		renote: '@accent',
 		link: '@accent',
 		mention: '@accent',
 		hashtag: '@accent',
+		panelHeaderDivider: '@divider',
 	},
 }
diff --git a/src/client/themes/rainy.json5 b/src/client/themes/rainy.json5
index 0ad633829..a7dc18164 100644
--- a/src/client/themes/rainy.json5
+++ b/src/client/themes/rainy.json5
@@ -11,5 +11,6 @@
 		bg: 'rgb(220, 229, 232)',
 		fg: 'rgb(139, 153, 161)',
 		renote: '@accent',
+		panelHeaderDivider: '@divider',
 	},
 }
diff --git a/src/client/themes/white.json5 b/src/client/themes/white.json5
index 5e2e1d730..4c3db53ac 100644
--- a/src/client/themes/white.json5
+++ b/src/client/themes/white.json5
@@ -8,7 +8,11 @@
 	base: 'light',
 
 	props: {
+		bg: '#f2f2f2',
+		header: ':alpha<0.7<@bg',
+		navBg: '@bg',
 		panelHeaderDivider: '@divider',
 		messageBg: '#dedede',
+		deckColumnBorder: '#cccccc',
 	},
 }
diff --git a/src/client/widgets/activity.vue b/src/client/widgets/activity.vue
index 4fdd81ae5..58b163136 100644
--- a/src/client/widgets/activity.vue
+++ b/src/client/widgets/activity.vue
@@ -1,18 +1,16 @@
 <template>
-<div>
-	<mk-container :show-header="props.design === 0" :naked="props.design === 2">
-		<template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template>
-		<template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template>
+<mk-container :show-header="props.showHeader" :naked="props.transparent">
+	<template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template>
+	<template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template>
 
-		<div>
-			<mk-loading v-if="fetching"/>
-			<template v-else>
-				<x-calendar v-show="props.view === 0" :data="[].concat(activity)"/>
-				<x-chart v-show="props.view === 1" :data="[].concat(activity)"/>
-			</template>
-		</div>
-	</mk-container>
-</div>
+	<div>
+		<mk-loading v-if="fetching"/>
+		<template v-else>
+			<x-calendar v-show="props.view === 0" :data="[].concat(activity)"/>
+			<x-chart v-show="props.view === 1" :data="[].concat(activity)"/>
+		</template>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -25,8 +23,19 @@ import XChart from './activity.chart.vue';
 export default define({
 	name: 'activity',
 	props: () => ({
-		design: 0,
-		view: 0
+		showHeader: {
+			type: 'boolean',
+			default: true,
+		},
+		transparent: {
+			type: 'boolean',
+			default: false,
+		},
+		view: {
+			type: 'number',
+			default: 0,
+			hidden: true,
+		},
 	})
 }).extend({
 	components: {
@@ -57,14 +66,6 @@ export default define({
 		});
 	},
 	methods: {
-		func() {
-			if (this.props.design === 2) {
-				this.props.design = 0;
-			} else {
-				this.props.design++;
-			}
-			this.save();
-		},
 		toggleView() {
 			if (this.props.view === 1) {
 				this.props.view = 0;
diff --git a/src/client/widgets/calendar.vue b/src/client/widgets/calendar.vue
index a29f73d3c..8ef74ff74 100644
--- a/src/client/widgets/calendar.vue
+++ b/src/client/widgets/calendar.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="mkw-calendar" :class="{ _panel: props.design === 0 }">
+<div class="mkw-calendar" :class="{ _panel: !props.transparent }">
 	<div class="calendar" :data-is-holiday="isHoliday">
 		<p class="month-and-year">
 			<span class="year">{{ $t('yearX', { year }) }}</span>
@@ -37,7 +37,10 @@ import define from './define';
 export default define({
 	name: 'calendar',
 	props: () => ({
-		design: 0
+		transparent: {
+			type: 'boolean',
+			default: false,
+		},
 	})
 }).extend({
 	data() {
@@ -62,14 +65,6 @@ export default define({
 		clearInterval(this.clock);
 	},
 	methods: {
-		func() {
-			if (this.props.design === 2) {
-				this.props.design = 0;
-			} else {
-				this.props.design++;
-			}
-			this.save();
-		},
 		tick() {
 			const now = new Date();
 			const nd = now.getDate();
diff --git a/src/client/widgets/clock.vue b/src/client/widgets/clock.vue
index 8e6189803..638832412 100644
--- a/src/client/widgets/clock.vue
+++ b/src/client/widgets/clock.vue
@@ -1,11 +1,9 @@
 <template>
-<div>
-	<mk-container :naked="props.style % 2 === 0" :show-header="false">
-		<div class="vubelbmv">
-			<mk-analog-clock class="clock" :smooth="props.style < 2"/>
-		</div>
-	</mk-container>
-</div>
+<mk-container :naked="props.transparent" :show-header="false">
+	<div class="vubelbmv">
+		<mk-analog-clock class="clock"/>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -16,19 +14,16 @@ import MkAnalogClock from '../components/analog-clock.vue';
 export default define({
 	name: 'clock',
 	props: () => ({
-		style: 0
+		transparent: {
+			type: 'boolean',
+			default: false,
+		},
 	})
 }).extend({
 	components: {
 		MkContainer,
 		MkAnalogClock
 	},
-	methods: {
-		func() {
-			this.props.style = (this.props.style + 1) % 4;
-			this.save();
-		}
-	}
 });
 </script>
 
diff --git a/src/client/widgets/define.ts b/src/client/widgets/define.ts
index 96b1b4ab5..107045bf4 100644
--- a/src/client/widgets/define.ts
+++ b/src/client/widgets/define.ts
@@ -1,6 +1,7 @@
 import Vue from 'vue';
+import { Form } from '../scripts/form';
 
-export default function <T extends object>(data: {
+export default function <T extends Form>(data: {
 	name: string;
 	props?: () => T;
 }) {
@@ -15,22 +16,22 @@ export default function <T extends object>(data: {
 			}
 		},
 
+		data() {
+			return {
+				bakedOldProps: null
+			};
+		},
+
 		computed: {
 			id(): string {
 				return this.widget.id;
 			},
 
-			props(): T {
+			props(): Record<string, any> {
 				return this.widget.data;
 			}
 		},
 
-		data() {
-			return {
-				bakedOldProps: null
-			};
-		},
-
 		created() {
 			this.mergeProps();
 
@@ -45,11 +46,26 @@ export default function <T extends object>(data: {
 					const defaultProps = data.props();
 					for (const prop of Object.keys(defaultProps)) {
 						if (this.props.hasOwnProperty(prop)) continue;
-						Vue.set(this.props, prop, defaultProps[prop]);
+						Vue.set(this.props, prop, defaultProps[prop].default);
 					}
 				}
 			},
 
+			async setting() {
+				const form = data.props();
+				for (const item of Object.keys(form)) {
+					form[item].default = this.props[item];
+				}
+				const { canceled, result } = await this.$root.form(data.name, form);
+				if (canceled) return;
+
+				for (const key of Object.keys(result)) {
+					Vue.set(this.props, key, result[key]);
+				}
+
+				this.save();
+			},
+
 			save() {
 				this.$store.commit('deviceUser/updateWidget', this.widget);
 			}
diff --git a/src/client/widgets/digital-clock.vue b/src/client/widgets/digital-clock.vue
new file mode 100644
index 000000000..0e68fe0ff
--- /dev/null
+++ b/src/client/widgets/digital-clock.vue
@@ -0,0 +1,75 @@
+<template>
+<div class="mkw-digitalClock" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }">
+	<span>
+		<span v-text="hh"></span>
+		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
+		<span v-text="mm"></span>
+		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
+		<span v-text="ss"></span>
+		<span :style="{ visibility: showColon ? 'visible' : 'hidden' }" v-if="props.showMs">:</span>
+		<span v-text="ms" v-if="props.showMs"></span>
+	</span>
+</div>
+</template>
+
+<script lang="ts">
+import define from './define';
+
+export default define({
+	name: 'digitalClock',
+	props: () => ({
+		transparent: {
+			type: 'boolean',
+			default: false,
+		},
+		fontSize: {
+			type: 'number',
+			default: 1.5,
+			step: 0.1,
+		},
+		showMs: {
+			type: 'boolean',
+			default: true,
+		},
+	})
+}).extend({
+	data() {
+		return {
+			clock: null,
+			hh: null,
+			mm: null,
+			ss: null,
+			ms: null,
+			showColon: true,
+		};
+	},
+	created() {
+		this.tick();
+		this.$watch('props.showMs', () => {
+			if (this.clock) clearInterval(this.clock);
+			this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000);
+		}, { immediate: true });
+	},
+	beforeDestroy() {
+		clearInterval(this.clock);
+	},
+	methods: {
+		tick() {
+			const now = new Date();
+			this.hh = now.getHours().toString().padStart(2, '0');
+			this.mm = now.getMinutes().toString().padStart(2, '0');
+			this.ss = now.getSeconds().toString().padStart(2, '0');
+			this.ms = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
+			this.showColon = now.getSeconds() % 2 === 0;
+		}
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-digitalClock {
+	padding: 16px 0;
+	font-family: Lucida Console, Courier, monospace;
+	text-align: center;
+}
+</style>
diff --git a/src/client/widgets/index.ts b/src/client/widgets/index.ts
index 878d42c0c..2d27d27e5 100644
--- a/src/client/widgets/index.ts
+++ b/src/client/widgets/index.ts
@@ -10,3 +10,17 @@ Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default));
 Vue.component('mkw-clock', () => import('./clock.vue').then(m => m.default));
 Vue.component('mkw-activity', () => import('./activity.vue').then(m => m.default));
 Vue.component('mkw-photos', () => import('./photos.vue').then(m => m.default));
+Vue.component('mkw-digitalClock', () => import('./digital-clock.vue').then(m => m.default));
+
+export const widgets = [
+	'memo',
+	'notifications',
+	'timeline',
+	'calendar',
+	'rss',
+	'trends',
+	'clock',
+	'activity',
+	'photos',
+	'digitalClock',
+];
diff --git a/src/client/widgets/memo.vue b/src/client/widgets/memo.vue
index cdc716b9f..0d319b225 100644
--- a/src/client/widgets/memo.vue
+++ b/src/client/widgets/memo.vue
@@ -1,14 +1,12 @@
 <template>
-<div>
-	<mk-container :show-header="!props.compact">
-		<template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template>
+<mk-container :show-header="props.showHeader">
+	<template #header><fa :icon="faStickyNote"/>{{ $t('_widgets.memo') }}</template>
 
-		<div class="otgbylcu">
-			<textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea>
-			<button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $t('save') }}</button>
-		</div>
-	</mk-container>
-</div>
+	<div class="otgbylcu">
+		<textarea v-model="text" :placeholder="$t('placeholder')" @input="onChange"></textarea>
+		<button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $t('save') }}</button>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -19,10 +17,12 @@ import define from './define';
 export default define({
 	name: 'memo',
 	props: () => ({
-		compact: false
+		showHeader: {
+			type: 'boolean',
+			default: true,
+		},
 	})
 }).extend({
-	
 	components: {
 		MkContainer
 	},
@@ -45,11 +45,6 @@ export default define({
 	},
 
 	methods: {
-		func() {
-			this.props.compact = !this.props.compact;
-			this.save();
-		},
-
 		onChange() {
 			this.changed = true;
 			clearTimeout(this.timeoutId);
diff --git a/src/client/widgets/notifications.vue b/src/client/widgets/notifications.vue
index 39fc8a936..24d7fe420 100644
--- a/src/client/widgets/notifications.vue
+++ b/src/client/widgets/notifications.vue
@@ -1,13 +1,11 @@
 <template>
-<div class="mkw-notifications" :style="`flex-basis: calc(${basis}% - var(--margin)); height: ${previewHeight}px;`">
-	<mk-container :show-header="!props.compact" class="container">
-		<template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template>
+<mk-container :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true">
+	<template #header><fa :icon="faBell"/>{{ $t('notifications') }}</template>
 
-		<div>
-			<x-notifications/>
-		</div>
-	</mk-container>
-</div>
+	<div>
+		<x-notifications/>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -16,17 +14,19 @@ import MkContainer from '../components/ui/container.vue';
 import XNotifications from '../components/notifications.vue';
 import define from './define';
 
-const basisSteps = [25, 50, 75, 100]
-const previewHeights = [200, 300, 400, 500]
-
 export default define({
 	name: 'notifications',
 	props: () => ({
-		compact: false,
-		basisStep: 0
+		showHeader: {
+			type: 'boolean',
+			default: true,
+		},
+		height: {
+			type: 'number',
+			default: 300,
+		},
 	})
 }).extend({
-	
 	components: {
 		MkContainer,
 		XNotifications,
@@ -37,47 +37,5 @@ export default define({
 			faBell
 		};
 	},
-
-	computed: {
-		basis(): number {
-			return basisSteps[this.props.basisStep] || 25
-		},
-
-		previewHeight(): number {
-			return previewHeights[this.props.basisStep] || 200
-		}
-	},
-
-	methods: {
-		func() {
-			if (this.props.basisStep === basisSteps.length - 1) {
-				this.props.basisStep = 0
-				this.props.compact = !this.props.compact;
-			} else {
-				this.props.basisStep += 1
-			}
-
-			this.save();
-		}
-	}
 });
 </script>
-
-<style lang="scss">
-.mkw-notifications {
-	flex-grow: 1;
-	flex-shrink: 0;
-	min-height: 0; // https://www.gwtcenter.com/min-height-required-on-firefox-flexbox
-
-	.container {
-		display: flex;
-		flex-direction: column;
-		height: 100%;
-
-		> div {
-			overflow: auto;
-			flex-grow: 1;
-		}
-	}
-}
-</style>
diff --git a/src/client/widgets/photos.vue b/src/client/widgets/photos.vue
index 6e4e43a56..2b8399df9 100644
--- a/src/client/widgets/photos.vue
+++ b/src/client/widgets/photos.vue
@@ -1,19 +1,17 @@
 <template>
-<div>
-	<mk-container :show-header="props.design === 0" :naked="props.design === 2" :class="$style.root" :data-melt="props.design === 2">
-		<template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template>
+<mk-container :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent">
+	<template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template>
 
-		<div class="">
-			<mk-loading v-if="fetching"/>
-			<div v-else :class="$style.stream">
-				<div v-for="(image, i) in images" :key="i"
-					:class="$style.img"
-					:style="`background-image: url(${thumbnail(image)})`"
-				></div>
-			</div>
+	<div class="">
+		<mk-loading v-if="fetching"/>
+		<div v-else :class="$style.stream">
+			<div v-for="(image, i) in images" :key="i"
+				:class="$style.img"
+				:style="`background-image: url(${thumbnail(image)})`"
+			></div>
 		</div>
-	</mk-container>
-</div>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -25,7 +23,14 @@ import { getStaticImageUrl } from '../scripts/get-static-image-url';
 export default define({
 	name: 'photos',
 	props: () => ({
-		design: 0,
+		showHeader: {
+			type: 'boolean',
+			default: true,
+		},
+		transparent: {
+			type: 'boolean',
+			default: false,
+		},
 	})
 }).extend({
 	components: {
@@ -63,15 +68,6 @@ export default define({
 			}
 		},
 
-		func() {
-			if (this.props.design === 2) {
-				this.props.design = 0;
-			} else {
-				this.props.design++;
-			}
-			this.save();
-		},
-
 		thumbnail(image: any): string {
 			return this.$store.state.device.disableShowingAnimatedImages
 				? getStaticImageUrl(image.thumbnailUrl)
@@ -82,7 +78,7 @@ export default define({
 </script>
 
 <style lang="scss" module>
-.root[data-melt] {
+.root[data-transparent] {
 	.stream {
 		padding: 0;
 	}
diff --git a/src/client/widgets/rss.vue b/src/client/widgets/rss.vue
index 4e57281e9..3a76c8fb4 100644
--- a/src/client/widgets/rss.vue
+++ b/src/client/widgets/rss.vue
@@ -1,17 +1,15 @@
 <template>
-<div>
-	<mk-container :show-header="!props.compact">
-		<template #header><fa :icon="faRssSquare"/>RSS</template>
-		<template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template>
+<mk-container :show-header="props.showHeader">
+	<template #header><fa :icon="faRssSquare"/>RSS</template>
+	<template #func><button class="_button" @click="setting"><fa :icon="faCog"/></button></template>
 
-		<div class="ekmkgxbj">
-			<mk-loading v-if="fetching"/>
-			<div class="feed" v-else>
-				<a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
-			</div>
+	<div class="ekmkgxbj">
+		<mk-loading v-if="fetching"/>
+		<div class="feed" v-else>
+			<a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
 		</div>
-	</mk-container>
-</div>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -22,8 +20,14 @@ import define from './define';
 export default define({
 	name: 'rss',
 	props: () => ({
-		compact: false,
-		url: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'
+		showHeader: {
+			type: 'boolean',
+			default: true,
+		},
+		url: {
+			type: 'string',
+			default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+		},
 	})
 }).extend({
 	components: {
@@ -40,15 +44,12 @@ export default define({
 	mounted() {
 		this.fetch();
 		this.clock = setInterval(this.fetch, 60000);
+		this.$watch('props.url', this.fetch);
 	},
 	beforeDestroy() {
 		clearInterval(this.clock);
 	},
 	methods: {
-		func() {
-			this.props.compact = !this.props.compact;
-			this.save();
-		},
 		fetch() {
 			fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, {
 			}).then(res => {
@@ -58,20 +59,6 @@ export default define({
 				});
 			});
 		},
-		setting() {
-			this.$root.dialog({
-				title: 'URL',
-				input: {
-					type: 'url',
-					default: this.props.url
-				}
-			}).then(({ canceled, result: url }) => {
-				if (canceled) return;
-				this.props.url = url;
-				this.save();
-				this.fetch();
-			});
-		}
 	}
 });
 </script>
diff --git a/src/client/widgets/timeline.vue b/src/client/widgets/timeline.vue
index 633131182..fb7486cb7 100644
--- a/src/client/widgets/timeline.vue
+++ b/src/client/widgets/timeline.vue
@@ -1,24 +1,22 @@
 <template>
-<div class="mkw-timeline" :style="`flex-basis: calc(${basis}% - var(--margin)); height: ${previewHeight}px;`">
-	<mk-container :show-header="!props.compact" class="container">
-		<template #header>
-			<button @click="choose" class="_button">
-				<fa v-if="props.src === 'home'" :icon="faHome"/>
-				<fa v-if="props.src === 'local'" :icon="faComments"/>
-				<fa v-if="props.src === 'social'" :icon="faShareAlt"/>
-				<fa v-if="props.src === 'global'" :icon="faGlobe"/>
-				<fa v-if="props.src === 'list'" :icon="faListUl"/>
-				<fa v-if="props.src === 'antenna'" :icon="faSatellite"/>
-				<span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
-				<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
-			</button>
-		</template>
+<mk-container :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true">
+	<template #header>
+		<button @click="choose" class="_button">
+			<fa v-if="props.src === 'home'" :icon="faHome"/>
+			<fa v-if="props.src === 'local'" :icon="faComments"/>
+			<fa v-if="props.src === 'social'" :icon="faShareAlt"/>
+			<fa v-if="props.src === 'global'" :icon="faGlobe"/>
+			<fa v-if="props.src === 'list'" :icon="faListUl"/>
+			<fa v-if="props.src === 'antenna'" :icon="faSatellite"/>
+			<span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
+			<fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
+		</button>
+	</template>
 
-		<div>
-			<x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list" :antenna="props.antenna"/>
-		</div>
-	</mk-container>
-</div>
+	<div>
+		<x-timeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -28,19 +26,25 @@ import MkContainer from '../components/ui/container.vue';
 import XTimeline from '../components/timeline.vue';
 import define from './define';
 
-const basisSteps = [25, 50, 75, 100]
-const previewHeights = [200, 300, 400, 500]
-
 export default define({
 	name: 'timeline',
 	props: () => ({
-		src: 'home',
-		list: null,
-		compact: false,
-		basisStep: 0
+		showHeader: {
+			type: 'boolean',
+			default: true,
+		},
+		src: {
+			type: 'string',
+			default: 'home',
+			hidden: true,
+		},
+		list: {
+			type: 'object',
+			default: null,
+			hidden: true,
+		},
 	})
 }).extend({
-	
 	components: {
 		MkContainer,
 		XTimeline,
@@ -53,28 +57,7 @@ export default define({
 		};
 	},
 
-	computed: {
-		basis(): number {
-			return basisSteps[this.props.basisStep] || 25
-		},
-
-		previewHeight(): number {
-			return previewHeights[this.props.basisStep] || 200
-		}
-	},
-
 	methods: {
-		func() {
-			if (this.props.basisStep === basisSteps.length - 1) {
-				this.props.basisStep = 0
-				this.props.compact = !this.props.compact;
-			} else {
-				this.props.basisStep += 1
-			}
-
-			this.save();
-		},
-
 		async choose(ev) {
 			this.menuOpened = true;
 			const [antennas, lists] = await Promise.all([
@@ -129,22 +112,3 @@ export default define({
 	}
 });
 </script>
-
-<style lang="scss">
-.mkw-timeline {
-	flex-grow: 1;
-	flex-shrink: 0;
-	min-height: 0; // https://www.gwtcenter.com/min-height-required-on-firefox-flexbox
-
-	.container {
-		display: flex;
-		flex-direction: column;
-		height: 100%;
-
-		> div {
-			overflow: auto;
-			flex-grow: 1;
-		}
-	}
-}
-</style>
diff --git a/src/client/widgets/trends.vue b/src/client/widgets/trends.vue
index 61f5bfbd3..d4a4b2d28 100644
--- a/src/client/widgets/trends.vue
+++ b/src/client/widgets/trends.vue
@@ -1,22 +1,20 @@
 <template>
-<div>
-	<mk-container :show-header="!props.compact">
-		<template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template>
+<mk-container :show-header="props.showHeader">
+	<template #header><fa :icon="faHashtag"/>{{ $t('_widgets.trends') }}</template>
 
-		<div class="wbrkwala">
-			<mk-loading v-if="fetching"/>
-			<transition-group tag="div" name="chart" class="tags" v-else>
-				<div v-for="stat in stats" :key="stat.tag">
-					<div class="tag">
-						<router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link>
-						<p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
-					</div>
-					<x-chart class="chart" :src="stat.chart"/>
+	<div class="wbrkwala">
+		<mk-loading v-if="fetching"/>
+		<transition-group tag="div" name="chart" class="tags" v-else>
+			<div v-for="stat in stats" :key="stat.tag">
+				<div class="tag">
+					<router-link class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</router-link>
+					<p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
 				</div>
-			</transition-group>
-		</div>
-	</mk-container>
-</div>
+				<x-chart class="chart" :src="stat.chart"/>
+			</div>
+		</transition-group>
+	</div>
+</mk-container>
 </template>
 
 <script lang="ts">
@@ -28,7 +26,10 @@ import XChart from './trends.chart.vue';
 export default define({
 	name: 'hashtags',
 	props: () => ({
-		compact: false
+		showHeader: {
+			type: 'boolean',
+			default: true,
+		},
 	})
 }).extend({
 	components: {
@@ -49,10 +50,6 @@ export default define({
 		clearInterval(this.clock);
 	},
 	methods: {
-		func() {
-			this.props.compact = !this.props.compact;
-			this.save();
-		},
 		fetch() {
 			this.$root.api('hashtags/trend').then(stats => {
 				this.stats = stats;