From 5b7595d9d7e313271c692a849fc915e73fcea108 Mon Sep 17 00:00:00 2001
From: futchitwo <74236683+futchitwo@users.noreply.github.com>
Date: Sat, 18 Jun 2022 18:27:09 +0900
Subject: Improve: unclip (#8823)

* Refactor clip page to use Composition API

* Refactor clip page

* Refactor clip page

* Refactor clip page

* Improve: unclip

* Fix unclip

* Fix unclip

* chore: better type and name

* Fix

* Fix clipPage vue provider

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
---
 packages/client/src/components/note.vue | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

(limited to 'packages/client/src/components/note.vue')

diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index e5744d1ce9..b06f4edd0a 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -105,7 +105,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
+import { computed, inject, onMounted, onUnmounted, reactive, ref, Ref } from 'vue';
 import * as mfm from 'mfm-js';
 import * as misskey from 'misskey-js';
 import MkNoteSub from './MkNoteSub.vue';
@@ -225,6 +225,8 @@ function undoReact(note): void {
 	});
 }
 
+const cullentClipPage = inject<Ref<misskey.entities.Clip>>('cullentClipPage');
+
 function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
 		if (el.tagName === 'A') return true;
@@ -239,12 +241,12 @@ function onContextmenu(ev: MouseEvent): void {
 		ev.preventDefault();
 		react();
 	} else {
-		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton }), ev).then(focus);
+		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, cullentClipPage }), ev).then(focus);
 	}
 }
 
 function menu(viaKeyboard = false): void {
-	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton }), menuButton.value, {
+	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, cullentClipPage }), menuButton.value, {
 		viaKeyboard,
 	}).then(focus);
 }
-- 
cgit v1.2.3-freya


From 802a35d4b6f438f9f931eb5cd791e950deaa445b Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 18 Jun 2022 18:27:47 +0900
Subject: fix typo

---
 packages/client/src/components/note.vue      | 6 +++---
 packages/client/src/pages/clip.vue           | 2 +-
 packages/client/src/scripts/get-note-menu.ts | 8 ++++----
 3 files changed, 8 insertions(+), 8 deletions(-)

(limited to 'packages/client/src/components/note.vue')

diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index b06f4edd0a..9ec1e53c1e 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -225,7 +225,7 @@ function undoReact(note): void {
 	});
 }
 
-const cullentClipPage = inject<Ref<misskey.entities.Clip>>('cullentClipPage');
+const currentClipPage = inject<Ref<misskey.entities.Clip>>('currentClipPage');
 
 function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
@@ -241,12 +241,12 @@ function onContextmenu(ev: MouseEvent): void {
 		ev.preventDefault();
 		react();
 	} else {
-		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, cullentClipPage }), ev).then(focus);
+		os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus);
 	}
 }
 
 function menu(viaKeyboard = false): void {
-	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, cullentClipPage }), menuButton.value, {
+	os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, {
 		viaKeyboard,
 	}).then(focus);
 }
diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue
index bfe1578b13..163384337e 100644
--- a/packages/client/src/pages/clip.vue
+++ b/packages/client/src/pages/clip.vue
@@ -47,7 +47,7 @@ watch(() => props.clipId, async () => {
 	immediate: true,
 }); 
 
-provide('cullentClipPage', $$(clip));
+provide('currentClipPage', $$(clip));
 
 defineExpose({
 	[symbols.PAGE_INFO]: computed(() => clip ? {
diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts
index e56d519493..283c90362c 100644
--- a/packages/client/src/scripts/get-note-menu.ts
+++ b/packages/client/src/scripts/get-note-menu.ts
@@ -15,7 +15,7 @@ export function getNoteMenu(props: {
 	translation: Ref<any>;
 	translating: Ref<boolean>;
 	isDeleted: Ref<boolean>;
-	cullentClipPage?: Ref<misskey.entities.Clip>;
+	currentClipPage?: Ref<misskey.entities.Clip>;
 }) {
 	const isRenote = (
 		props.note.renote != null &&
@@ -138,7 +138,7 @@ export function getNoteMenu(props: {
 							});
 							if (!confirm.canceled) {
 								os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
-								if (props.cullentClipPage?.value.id === clip.id) props.isDeleted.value = true;
+								if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
 							}
 						} else {
 							os.alert({
@@ -154,7 +154,7 @@ export function getNoteMenu(props: {
 	}
 
 	async function unclip(): Promise<void> {
-		os.apiWithDialog('clips/remove-note', { clipId: props.cullentClipPage.value.id, noteId: appearNote.id });
+		os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
 		props.isDeleted.value = true;
 	}
 
@@ -198,7 +198,7 @@ export function getNoteMenu(props: {
 
 		menu = [
 		...(
-			props.cullentClipPage?.value.userId === $i.id ? [{
+			props.currentClipPage?.value.userId === $i.id ? [{
 				icon: 'fas fa-circle-minus',
 				text: i18n.ts.unclip,
 				danger: true,
-- 
cgit v1.2.3-freya


From 699f24f3dcdb156838eb70602885c0b2cdd02cbc Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Mon, 20 Jun 2022 17:38:49 +0900
Subject: refactor(client): Refine routing (#8846)

---
 packages/client/package.json                       |   1 -
 packages/client/src/account.ts                     |  34 +-
 packages/client/src/components/chart.vue           |  94 +--
 .../client/src/components/drive-file-thumbnail.vue |   2 +-
 packages/client/src/components/form/folder.vue     |   4 +-
 packages/client/src/components/global/a.vue        |  31 +-
 packages/client/src/components/global/header.vue   | 361 -----------
 .../client/src/components/global/page-header.vue   | 300 +++++++++
 .../client/src/components/global/router-view.vue   |  39 ++
 packages/client/src/components/index.ts            |   9 +-
 .../client/src/components/modal-page-window.vue    | 215 +++----
 packages/client/src/components/note.vue            |   2 +-
 packages/client/src/components/page-window.vue     | 257 ++++----
 packages/client/src/components/ui/window.vue       |  69 ++-
 packages/client/src/init.ts                        |  14 +-
 packages/client/src/menu.ts                        |  31 +-
 packages/client/src/nirax.ts                       | 200 ++++++
 packages/client/src/os.ts                          |   7 -
 packages/client/src/pages/_error_.vue              |  14 +-
 packages/client/src/pages/about-misskey.vue        | 131 ++--
 packages/client/src/pages/about.vue                | 149 ++---
 packages/client/src/pages/admin-file.vue           |  69 ++-
 packages/client/src/pages/admin/_header_.vue       | 249 ++++++++
 packages/client/src/pages/admin/abuses.vue         |  80 +--
 packages/client/src/pages/admin/ads.vue            | 103 ++--
 packages/client/src/pages/admin/announcements.vue  |  81 +--
 packages/client/src/pages/admin/bot-protection.vue |   1 -
 packages/client/src/pages/admin/database.vue       |  23 +-
 packages/client/src/pages/admin/email-settings.vue | 121 ++--
 packages/client/src/pages/admin/emojis.vue         | 186 +++---
 packages/client/src/pages/admin/files.vue          | 119 ++--
 packages/client/src/pages/admin/index.vue          |  50 +-
 packages/client/src/pages/admin/instance-block.vue |  40 +-
 packages/client/src/pages/admin/integrations.vue   |  27 +-
 packages/client/src/pages/admin/object-storage.vue | 153 ++---
 packages/client/src/pages/admin/other-settings.vue |  41 +-
 packages/client/src/pages/admin/overview.vue       |  22 +-
 packages/client/src/pages/admin/proxy-account.vue  |  23 +-
 packages/client/src/pages/admin/queue.vue          |  57 +-
 packages/client/src/pages/admin/relays.vue         |  63 +-
 packages/client/src/pages/admin/security.vue       |  70 ++-
 packages/client/src/pages/admin/settings.vue       | 301 ++++-----
 packages/client/src/pages/admin/users.vue          | 172 +++---
 packages/client/src/pages/announcements.vue        |  86 ++-
 packages/client/src/pages/antenna-timeline.vue     | 145 ++---
 packages/client/src/pages/api-console.vue          |  65 +-
 packages/client/src/pages/auth.vue                 |  18 +-
 packages/client/src/pages/channel-editor.vue       | 219 ++++---
 packages/client/src/pages/channel.vue              | 153 +++--
 packages/client/src/pages/channels.vue             | 149 ++---
 packages/client/src/pages/clip.vue                 | 130 ++--
 packages/client/src/pages/drive.vue                |  20 +-
 packages/client/src/pages/emojis.category.vue      |   1 -
 packages/client/src/pages/emojis.vue               |  36 +-
 packages/client/src/pages/explore.vue              | 245 ++++----
 packages/client/src/pages/favorites.vue            |  45 +-
 packages/client/src/pages/featured.vue             |  21 +-
 packages/client/src/pages/federation.vue           | 197 +++---
 packages/client/src/pages/follow-requests.vue      |  20 +-
 packages/client/src/pages/follow.vue               |  15 +-
 packages/client/src/pages/gallery/edit.vue         | 181 +++---
 packages/client/src/pages/gallery/index.vue        | 199 +++---
 packages/client/src/pages/gallery/post.vue         | 195 +++---
 packages/client/src/pages/instance-info.vue        |  39 +-
 packages/client/src/pages/mentions.vue             |  23 +-
 packages/client/src/pages/messages.vue             |  25 +-
 packages/client/src/pages/messaging/index.vue      | 290 ++++-----
 .../client/src/pages/messaging/messaging-room.vue  |  18 +-
 packages/client/src/pages/mfm-cheat-sheet.vue      | 536 ++++++++--------
 packages/client/src/pages/miauth.vue               |  26 +-
 packages/client/src/pages/my-antennas/create.vue   |  24 +-
 packages/client/src/pages/my-antennas/edit.vue     |  22 +-
 packages/client/src/pages/my-antennas/index.vue    |  23 +-
 packages/client/src/pages/my-clips/index.vue       |  29 +-
 packages/client/src/pages/my-groups/group.vue      | 178 ------
 packages/client/src/pages/my-groups/index.vue      | 147 -----
 packages/client/src/pages/my-lists/index.vue       |  29 +-
 packages/client/src/pages/my-lists/list.vue        | 185 +++---
 packages/client/src/pages/not-found.vue            |  16 +-
 packages/client/src/pages/note.vue                 | 198 +++---
 packages/client/src/pages/notifications.vue        |  77 +--
 .../client/src/pages/page-editor/page-editor.vue   | 675 ++++++++++-----------
 packages/client/src/pages/page.vue                 | 205 +++----
 packages/client/src/pages/pages.vue                | 149 ++---
 packages/client/src/pages/preview.vue              |  18 +-
 packages/client/src/pages/reset-password.vue       |  43 +-
 packages/client/src/pages/scratchpad.vue           |  26 +-
 packages/client/src/pages/search.vue               |  29 +-
 .../client/src/pages/settings/account-info.vue     |  18 +-
 packages/client/src/pages/settings/accounts.vue    |  26 +-
 packages/client/src/pages/settings/api.vue         |  20 +-
 packages/client/src/pages/settings/apps.vue        |  24 +-
 packages/client/src/pages/settings/custom-css.vue  |  18 +-
 packages/client/src/pages/settings/deck.vue        |  20 +-
 .../client/src/pages/settings/delete-account.vue   |  21 +-
 packages/client/src/pages/settings/drive.vue       |  26 +-
 packages/client/src/pages/settings/email.vue       |  24 +-
 packages/client/src/pages/settings/general.vue     |  23 +-
 .../client/src/pages/settings/import-export.vue    |  18 +-
 packages/client/src/pages/settings/index.vue       |  95 ++-
 .../client/src/pages/settings/instance-mute.vue    |  16 +-
 packages/client/src/pages/settings/integration.vue |  22 +-
 packages/client/src/pages/settings/menu.vue        |  26 +-
 packages/client/src/pages/settings/mute-block.vue  |  20 +-
 .../client/src/pages/settings/notifications.vue    |  22 +-
 packages/client/src/pages/settings/other.vue       |  20 +-
 .../client/src/pages/settings/plugin.install.vue   |  36 +-
 packages/client/src/pages/settings/plugin.vue      |  20 +-
 packages/client/src/pages/settings/privacy.vue     |  16 +-
 packages/client/src/pages/settings/profile.vue     |  16 +-
 packages/client/src/pages/settings/reaction.vue    |  26 +-
 packages/client/src/pages/settings/security.vue    |  35 +-
 packages/client/src/pages/settings/sounds.vue      |  40 +-
 .../client/src/pages/settings/theme.install.vue    |  24 +-
 .../client/src/pages/settings/theme.manage.vue     |  18 +-
 packages/client/src/pages/settings/theme.vue       |  23 +-
 .../client/src/pages/settings/webhook.edit.vue     |  22 +-
 packages/client/src/pages/settings/webhook.new.vue |  16 +-
 packages/client/src/pages/settings/webhook.vue     |  18 +-
 packages/client/src/pages/settings/word-mute.vue   |  20 +-
 packages/client/src/pages/share.vue                | 256 ++++----
 packages/client/src/pages/signup-complete.vue      |  14 +-
 packages/client/src/pages/tag.vue                  |  18 +-
 packages/client/src/pages/theme-editor.vue         |  44 +-
 packages/client/src/pages/timeline.vue             | 140 +++--
 packages/client/src/pages/user-info.vue            | 397 ++++++------
 packages/client/src/pages/user-list-timeline.vue   | 134 ++--
 packages/client/src/pages/user/index.vue           | 473 +++++++--------
 packages/client/src/pages/welcome.vue              |  16 +-
 packages/client/src/router.ts                      | 366 +++++++----
 packages/client/src/scripts/get-user-menu.ts       |  58 +-
 packages/client/src/scripts/navigate.ts            |  34 --
 packages/client/src/scripts/page-metadata.ts       |  41 ++
 packages/client/src/scripts/search.ts              |  16 +-
 packages/client/src/scripts/use-leave-guard.ts     |   1 -
 packages/client/src/symbols.ts                     |   1 -
 packages/client/src/ui/_common_/sw-inject.ts       |  15 +-
 packages/client/src/ui/classic.side.vue            | 148 -----
 packages/client/src/ui/classic.vue                 | 267 ++++----
 packages/client/src/ui/deck.vue                    |  33 +-
 packages/client/src/ui/deck/main-column.vue        |  40 +-
 packages/client/src/ui/desktop.vue                 |  70 ---
 packages/client/src/ui/universal.vue               |  91 +--
 packages/client/src/ui/visitor/a.vue               |  25 +-
 packages/client/src/ui/visitor/b.vue               | 146 ++---
 packages/client/src/ui/zen.vue                     |  99 +--
 packages/client/tsconfig.json                      |   3 +-
 packages/client/vite.config.ts                     |   6 +-
 packages/client/yarn.lock                          |  12 -
 149 files changed, 6290 insertions(+), 6648 deletions(-)
 delete mode 100644 packages/client/src/components/global/header.vue
 create mode 100644 packages/client/src/components/global/page-header.vue
 create mode 100644 packages/client/src/components/global/router-view.vue
 create mode 100644 packages/client/src/nirax.ts
 create mode 100644 packages/client/src/pages/admin/_header_.vue
 delete mode 100644 packages/client/src/pages/my-groups/group.vue
 delete mode 100644 packages/client/src/pages/my-groups/index.vue
 delete mode 100644 packages/client/src/scripts/navigate.ts
 create mode 100644 packages/client/src/scripts/page-metadata.ts
 delete mode 100644 packages/client/src/symbols.ts
 delete mode 100644 packages/client/src/ui/classic.side.vue
 delete mode 100644 packages/client/src/ui/desktop.vue

(limited to 'packages/client/src/components/note.vue')

diff --git a/packages/client/package.json b/packages/client/package.json
index 83c8086e23..f1ab23a55e 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -77,7 +77,6 @@
 		"vite": "2.9.10",
 		"vue": "3.2.37",
 		"vue-prism-editor": "2.0.0-alpha.2",
-		"vue-router": "4.0.16",
 		"vuedraggable": "4.0.1",
 		"websocket": "1.0.34",
 		"ws": "8.8.0"
diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts
index ce4af61f18..2b07dd1990 100644
--- a/packages/client/src/account.ts
+++ b/packages/client/src/account.ts
@@ -1,11 +1,11 @@
-import { del, get, set } from '@/scripts/idb-proxy';
 import { defineAsyncComponent, reactive } from 'vue';
 import * as misskey from 'misskey-js';
+import { showSuspendedDialog } from './scripts/show-suspended-dialog';
+import { i18n } from './i18n';
+import { del, get, set } from '@/scripts/idb-proxy';
 import { apiUrl } from '@/config';
 import { waiting, api, popup, popupMenu, success, alert } from '@/os';
 import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
-import { showSuspendedDialog } from './scripts/show-suspended-dialog';
-import { i18n } from './i18n';
 
 // TODO: 他のタブと永続化されたstateを同期
 
@@ -22,13 +22,7 @@ export async function signout() {
 	waiting();
 	localStorage.removeItem('account');
 
-	//#region Remove account
-	const accounts = await getAccounts();
-	accounts.splice(accounts.findIndex(x => x.id === $i.id), 1);
-
-	if (accounts.length > 0) await set('accounts', accounts);
-	else await del('accounts');
-	//#endregion
+	await removeAccount($i.id);
 
 	//#region Remove service worker registration
 	try {
@@ -55,7 +49,7 @@ export async function signout() {
 	} catch (err) {}
 	//#endregion
 
-	document.cookie = `igi=; path=/`;
+	document.cookie = 'igi=; path=/';
 
 	if (accounts.length > 0) login(accounts[0].token);
 	else unisonReload('/');
@@ -72,14 +66,22 @@ export async function addAccount(id: Account['id'], token: Account['token']) {
 	}
 }
 
+export async function removeAccount(id: Account['id']) {
+	const accounts = await getAccounts();
+	accounts.splice(accounts.findIndex(x => x.id === id), 1);
+
+	if (accounts.length > 0) await set('accounts', accounts);
+	else await del('accounts');
+}
+
 function fetchAccount(token: string): Promise<Account> {
 	return new Promise((done, fail) => {
 		// Fetch user
 		fetch(`${apiUrl}/i`, {
 			method: 'POST',
 			body: JSON.stringify({
-				i: token
-			})
+				i: token,
+			}),
 		})
 		.then(res => res.json())
 		.then(res => {
@@ -216,13 +218,13 @@ export async function openAccountMenu(opts: {
 			type: 'link',
 			icon: 'fas fa-users',
 			text: i18n.ts.manageAccounts,
-			to: `/settings/accounts`,
+			to: '/settings/accounts',
 		}]], ev.currentTarget ?? ev.target, {
-			align: 'left'
+			align: 'left',
 		});
 	} else {
 		popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
-			align: 'left'
+			align: 'left',
 		});
 	}
 }
diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue
index 4e9c4e587a..5e9c2f03be 100644
--- a/packages/client/src/components/chart.vue
+++ b/packages/client/src/components/chart.vue
@@ -13,7 +13,7 @@
   id-denylist violation when setting it. This is causing about 60+ lint issues.
   As this is part of Chart.js's API it makes sense to disable the check here.
 */
-import { defineProps, onMounted, ref, watch, PropType, onUnmounted } from 'vue';
+import { onMounted, ref, watch, PropType, onUnmounted } from 'vue';
 import {
 	Chart,
 	ArcElement,
@@ -53,7 +53,7 @@ const props = defineProps({
 	limit: {
 		type: Number,
 		required: false,
-		default: 90
+		default: 90,
 	},
 	span: {
 		type: String as PropType<'hour' | 'day'>,
@@ -62,22 +62,22 @@ const props = defineProps({
 	detailed: {
 		type: Boolean,
 		required: false,
-		default: false
+		default: false,
 	},
 	stacked: {
 		type: Boolean,
 		required: false,
-		default: false
+		default: false,
 	},
 	bar: {
 		type: Boolean,
 		required: false,
-		default: false
+		default: false,
 	},
 	aspectRatio: {
 		type: Number,
 		required: false,
-		default: null
+		default: null,
 	},
 });
 
@@ -156,7 +156,7 @@ const getDate = (ago: number) => {
 const format = (arr) => {
 	return arr.map((v, i) => ({
 		x: getDate(i).getTime(),
-		y: v
+		y: v,
 	}));
 };
 
@@ -343,7 +343,7 @@ const render = () => {
 							min: 'original',
 							max: 'original',
 						},
-					}
+					},
 				} : undefined,
 				//gradient,
 			},
@@ -367,8 +367,8 @@ const render = () => {
 					ctx.stroke();
 					ctx.restore();
 				}
-			}
-		}]
+			},
+		}],
 	});
 };
 
@@ -433,18 +433,18 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => {
 			name: 'In',
 			type: 'area',
 			color: '#008FFB',
-			data: format(raw.inboxReceived)
+			data: format(raw.inboxReceived),
 		}, {
 			name: 'Out (succ)',
 			type: 'area',
 			color: '#00E396',
-			data: format(raw.deliverSucceeded)
+			data: format(raw.deliverSucceeded),
 		}, {
 			name: 'Out (fail)',
 			type: 'area',
 			color: '#FEB019',
-			data: format(raw.deliverFailed)
-		}]
+			data: format(raw.deliverFailed),
+		}],
 	};
 };
 
@@ -456,7 +456,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'line',
 			data: format(type === 'combined'
 				? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
-				: sum(raw[type].inc, negate(raw[type].dec))
+				: sum(raw[type].inc, negate(raw[type].dec)),
 			),
 			color: '#888888',
 		}, {
@@ -464,7 +464,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
-				: raw[type].diffs.renote
+				: raw[type].diffs.renote,
 			),
 			color: colors.green,
 		}, {
@@ -472,7 +472,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
-				: raw[type].diffs.reply
+				: raw[type].diffs.reply,
 			),
 			color: colors.yellow,
 		}, {
@@ -480,7 +480,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
-				: raw[type].diffs.normal
+				: raw[type].diffs.normal,
 			),
 			color: colors.blue,
 		}, {
@@ -488,7 +488,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
 			type: 'area',
 			data: format(type === 'combined'
 				? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
-				: raw[type].diffs.withFile
+				: raw[type].diffs.withFile,
 			),
 			color: colors.purple,
 		}],
@@ -522,21 +522,21 @@ const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => {
 			type: 'line',
 			data: format(total
 				? sum(raw.local.total, raw.remote.total)
-				: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+				: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)),
 			),
 		}, {
 			name: 'Local',
 			type: 'area',
 			data: format(total
 				? raw.local.total
-				: sum(raw.local.inc, negate(raw.local.dec))
+				: sum(raw.local.inc, negate(raw.local.dec)),
 			),
 		}, {
 			name: 'Remote',
 			type: 'area',
 			data: format(total
 				? raw.remote.total
-				: sum(raw.remote.inc, negate(raw.remote.dec))
+				: sum(raw.remote.inc, negate(raw.remote.dec)),
 			),
 		}],
 	};
@@ -607,8 +607,8 @@ const fetchDriveChart = async (): Promise<typeof chartData> => {
 					raw.local.incSize,
 					negate(raw.local.decSize),
 					raw.remote.incSize,
-					negate(raw.remote.decSize)
-				)
+					negate(raw.remote.decSize),
+				),
 			),
 		}, {
 			name: 'Local +',
@@ -642,8 +642,8 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
 					raw.local.incCount,
 					negate(raw.local.decCount),
 					raw.remote.incCount,
-					negate(raw.remote.decCount)
-				)
+					negate(raw.remote.decCount),
+				),
 			),
 		}, {
 			name: 'Local +',
@@ -672,18 +672,18 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
 			name: 'In',
 			type: 'area',
 			color: '#008FFB',
-			data: format(raw.requests.received)
+			data: format(raw.requests.received),
 		}, {
 			name: 'Out (succ)',
 			type: 'area',
 			color: '#00E396',
-			data: format(raw.requests.succeeded)
+			data: format(raw.requests.succeeded),
 		}, {
 			name: 'Out (fail)',
 			type: 'area',
 			color: '#FEB019',
-			data: format(raw.requests.failed)
-		}]
+			data: format(raw.requests.failed),
+		}],
 	};
 };
 
@@ -696,9 +696,9 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData
 			color: '#008FFB',
 			data: format(total
 				? raw.users.total
-				: sum(raw.users.inc, negate(raw.users.dec))
-			)
-		}]
+				: sum(raw.users.inc, negate(raw.users.dec)),
+			),
+		}],
 	};
 };
 
@@ -711,9 +711,9 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData
 			color: '#008FFB',
 			data: format(total
 				? raw.notes.total
-				: sum(raw.notes.inc, negate(raw.notes.dec))
-			)
-		}]
+				: sum(raw.notes.inc, negate(raw.notes.dec)),
+			),
+		}],
 	};
 };
 
@@ -726,17 +726,17 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> =
 			color: '#008FFB',
 			data: format(total
 				? raw.following.total
-				: sum(raw.following.inc, negate(raw.following.dec))
-			)
+				: sum(raw.following.inc, negate(raw.following.dec)),
+			),
 		}, {
 			name: 'Followers',
 			type: 'area',
 			color: '#00E396',
 			data: format(total
 				? raw.followers.total
-				: sum(raw.followers.inc, negate(raw.followers.dec))
-			)
-		}]
+				: sum(raw.followers.inc, negate(raw.followers.dec)),
+			),
+		}],
 	};
 };
 
@@ -750,9 +750,9 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char
 			color: '#008FFB',
 			data: format(total
 				? raw.drive.totalUsage
-				: sum(raw.drive.incUsage, negate(raw.drive.decUsage))
-			)
-		}]
+				: sum(raw.drive.incUsage, negate(raw.drive.decUsage)),
+			),
+		}],
 	};
 };
 
@@ -765,9 +765,9 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char
 			color: '#008FFB',
 			data: format(total
 				? raw.drive.totalFiles
-				: sum(raw.drive.incFiles, negate(raw.drive.decFiles))
-			)
-		}]
+				: sum(raw.drive.incFiles, negate(raw.drive.decFiles)),
+			),
+		}],
 	};
 };
 
diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue
index 07cd565c58..b346585cec 100644
--- a/packages/client/src/components/drive-file-thumbnail.vue
+++ b/packages/client/src/components/drive-file-thumbnail.vue
@@ -57,7 +57,7 @@ const isThumbnailAvailable = computed(() => {
 .zdjebgpv {
 	position: relative;
 	display: flex;
-	background: #e1e1e1;
+	background: var(--panel);
 	border-radius: 8px;
 	overflow: clip;
 
diff --git a/packages/client/src/components/form/folder.vue b/packages/client/src/components/form/folder.vue
index 1b960657d7..a9d8bd97b8 100644
--- a/packages/client/src/components/form/folder.vue
+++ b/packages/client/src/components/form/folder.vue
@@ -9,13 +9,13 @@
 			<i v-else class="fas fa-angle-down icon"></i>
 		</span>
 	</div>
-	<keep-alive>
+	<KeepAlive>
 		<div v-if="openedAtLeastOnce" v-show="opened" class="body">
 			<MkSpacer :margin-min="14" :margin-max="22">
 				<slot></slot>
 			</MkSpacer>
 		</div>
-	</keep-alive>
+	</KeepAlive>
 </div>
 </template>
 
diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue
index 5287d59b3e..c7cf12e8c8 100644
--- a/packages/client/src/components/global/a.vue
+++ b/packages/client/src/components/global/a.vue
@@ -5,13 +5,13 @@
 </template>
 
 <script lang="ts" setup>
+import { inject } from 'vue';
 import * as os from '@/os';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { router } from '@/router';
 import { url } from '@/config';
 import { popout as popout_ } from '@/scripts/popout';
 import { i18n } from '@/i18n';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
 
 const props = withDefaults(defineProps<{
 	to: string;
@@ -22,15 +22,16 @@ const props = withDefaults(defineProps<{
 	behavior: null,
 });
 
-const mkNav = new MisskeyNavigator();
+const router = useRouter();
 
 const active = $computed(() => {
 	if (props.activeClass == null) return false;
 	const resolved = router.resolve(props.to);
-	if (resolved.path === router.currentRoute.value.path) return true;
-	if (resolved.name == null) return false;
+	if (resolved == null) return false;
+	if (resolved.route.path === router.currentRoute.value.path) return true;
+	if (resolved.route.name == null) return false;
 	if (router.currentRoute.value.name == null) return false;
-	return resolved.name === router.currentRoute.value.name;
+	return resolved.route.name === router.currentRoute.value.name;
 });
 
 function onContextmenu(ev) {
@@ -44,31 +45,25 @@ function onContextmenu(ev) {
 		text: i18n.ts.openInWindow,
 		action: () => {
 			os.pageWindow(props.to);
-		}
-	}, mkNav.sideViewHook ? {
-		icon: 'fas fa-columns',
-		text: i18n.ts.openInSideView,
-		action: () => {
-			if (mkNav.sideViewHook) mkNav.sideViewHook(props.to);
-		}
-	} : undefined, {
+		},
+	}, {
 		icon: 'fas fa-expand-alt',
 		text: i18n.ts.showInPage,
 		action: () => {
 			router.push(props.to);
-		}
+		},
 	}, null, {
 		icon: 'fas fa-external-link-alt',
 		text: i18n.ts.openInNewTab,
 		action: () => {
 			window.open(props.to, '_blank');
-		}
+		},
 	}, {
 		icon: 'fas fa-link',
 		text: i18n.ts.copyLink,
 		action: () => {
 			copyToClipboard(`${url}${props.to}`);
-		}
+		},
 	}], ev);
 }
 
@@ -98,6 +93,6 @@ function nav() {
 		}
 	}
 
-	mkNav.push(props.to);
+	router.push(props.to);
 }
 </script>
diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue
deleted file mode 100644
index 63db19a520..0000000000
--- a/packages/client/src/components/global/header.vue
+++ /dev/null
@@ -1,361 +0,0 @@
-<template>
-<div ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick">
-	<template v-if="info">
-		<div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup">
-			<MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
-			<i v-else-if="info.icon" class="icon" :class="info.icon"></i>
-
-			<div class="title">
-				<MkUserName v-if="info.userName" :user="info.userName" :nowrap="true" class="title"/>
-				<div v-else-if="info.title" class="title">{{ info.title }}</div>
-				<div v-if="!narrow && info.subtitle" class="subtitle">
-					{{ info.subtitle }}
-				</div>
-				<div v-if="narrow && hasTabs" class="subtitle activeTab">
-					{{ info.tabs.find(tab => tab.active)?.title }}
-					<i class="chevron fas fa-chevron-down"></i>
-				</div>
-			</div>
-		</div>
-		<div v-if="!narrow || hideTitle" class="tabs">
-			<button v-for="tab in info.tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
-				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
-				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
-			</button>
-		</div>
-	</template>
-	<div class="buttons right">
-		<template v-if="info && info.actions && !narrow">
-			<template v-for="action in info.actions">
-				<MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
-				<button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
-			</template>
-		</template>
-		<button v-if="shouldShowMenu" v-tooltip="$ts.menu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag"><i class="fas fa-ellipsis-h"></i></button>
-	</div>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue';
-import tinycolor from 'tinycolor2';
-import { popupMenu } from '@/os';
-import { url } from '@/config';
-import { scrollToTop } from '@/scripts/scroll';
-import MkButton from '@/components/ui/button.vue';
-import { i18n } from '@/i18n';
-import { globalEvents } from '@/events';
-
-export default defineComponent({
-	components: {
-		MkButton
-	},
-
-	props: {
-		info: {
-			type: Object as PropType<{
-				actions?: {}[];
-				tabs?: {}[];
-			}>,
-			required: true
-		},
-		menu: {
-			required: false
-		},
-		thin: {
-			required: false,
-			default: false
-		},
-	},
-
-	setup(props) {
-		const el = ref<HTMLElement>(null);
-		const bg = ref(null);
-		const narrow = ref(false);
-		const height = ref(0);
-		const hasTabs = computed(() => {
-			return props.info.tabs && props.info.tabs.length > 0;
-		});
-		const shouldShowMenu = computed(() => {
-			if (props.info == null) return false;
-			if (props.info.actions != null && narrow.value) return true;
-			if (props.info.menu != null) return true;
-			if (props.info.share != null) return true;
-			if (props.menu != null) return true;
-			return false;
-		});
-
-		const share = () => {
-			navigator.share({
-				url: url + props.info.path,
-				...props.info.share,
-			});
-		};
-
-		const showMenu = (ev: MouseEvent) => {
-			let menu = props.info.menu ? props.info.menu() : [];
-			if (narrow.value && props.info.actions) {
-				menu = [...props.info.actions.map(x => ({
-					text: x.text,
-					icon: x.icon,
-					action: x.handler
-				})), menu.length > 0 ? null : undefined, ...menu];
-			}
-			if (props.info.share) {
-				if (menu.length > 0) menu.push(null);
-				menu.push({
-					text: i18n.ts.share,
-					icon: 'fas fa-share-alt',
-					action: share
-				});
-			}
-			if (props.menu) {
-				if (menu.length > 0) menu.push(null);
-				menu = menu.concat(props.menu);
-			}
-			popupMenu(menu, ev.currentTarget ?? ev.target);
-		};
-
-		const showTabsPopup = (ev: MouseEvent) => {
-			if (!hasTabs.value) return;
-			if (!narrow.value) return;
-			ev.preventDefault();
-			ev.stopPropagation();
-			const menu = props.info.tabs.map(tab => ({
-				text: tab.title,
-				icon: tab.icon,
-				action: tab.onClick,
-			}));
-			popupMenu(menu, ev.currentTarget ?? ev.target);
-		};
-
-		const preventDrag = (ev: TouchEvent) => {
-			ev.stopPropagation();
-		};
-
-		const onClick = () => {
-			scrollToTop(el.value, { behavior: 'smooth' });
-		};
-
-		const calcBg = () => {
-			const rawBg = props.info?.bg || 'var(--bg)';
-			const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
-			tinyBg.setAlpha(0.85);
-			bg.value = tinyBg.toRgbString();
-		};
-
-		onMounted(() => {
-			calcBg();
-			globalEvents.on('themeChanged', calcBg);
-			onUnmounted(() => {
-				globalEvents.off('themeChanged', calcBg);
-			});
-		
-			if (el.value.parentElement) {
-				narrow.value = el.value.parentElement.offsetWidth < 500;
-				const ro = new ResizeObserver((entries, observer) => {
-					if (el.value) {
-						narrow.value = el.value.parentElement.offsetWidth < 500;
-					}
-				});
-				ro.observe(el.value.parentElement);
-				onUnmounted(() => {
-					ro.disconnect();
-				});
-			}
-		});
-
-		return {
-			el,
-			bg,
-			narrow,
-			height,
-			hasTabs,
-			shouldShowMenu,
-			share,
-			showMenu,
-			showTabsPopup,
-			preventDrag,
-			onClick,
-			hideTitle: inject('shouldOmitHeaderTitle', false),
-			thin_: props.thin || inject('shouldHeaderThin', false)
-		};
-	},
-});
-</script>
-
-<style lang="scss" scoped>
-.fdidabkb {
-	--height: 60px;
-	display: flex;
-	position: sticky;
-	top: var(--stickyTop, 0);
-	z-index: 1000;
-	width: 100%;
-	-webkit-backdrop-filter: var(--blur, blur(15px));
-	backdrop-filter: var(--blur, blur(15px));
-	border-bottom: solid 0.5px var(--divider);
-
-	&.thin {
-		--height: 50px;
-
-		> .buttons {
-			> .button {
-				font-size: 0.9em;
-			}
-		}
-	}
-
-	&.slim {
-		text-align: center;
-
-		> .titleContainer {
-			flex: 1;
-			margin: 0 auto;
-			margin-left: var(--height);
-
-			> *:first-child {
-				margin-left: auto;
-			}
-
-			> *:last-child {
-				margin-right: auto;
-			}
-		}
-	}
-
-	> .buttons {
-		--margin: 8px;
-		display: flex;
-    align-items: center;
-		height: var(--height);
-		margin: 0 var(--margin);
-
-		&.right {
-			margin-left: auto;
-		}
-
-		&:empty {
-			width: var(--height);
-		}
-
-		> .button {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			height: calc(var(--height) - (var(--margin) * 2));
-			width: calc(var(--height) - (var(--margin) * 2));
-			box-sizing: border-box;
-			position: relative;
-			border-radius: 5px;
-
-			&:hover {
-				background: rgba(0, 0, 0, 0.05);
-			}
-
-			&.highlighted {
-				color: var(--accent);
-			}
-		}
-
-		> .fullButton {
-			& + .fullButton {
-				margin-left: 12px;
-			}
-		}
-	}
-
-	> .titleContainer {
-		display: flex;
-		align-items: center;
-		max-width: 400px;
-		overflow: auto;
-		white-space: nowrap;
-		text-align: left;
-		font-weight: bold;
-		flex-shrink: 0;
-		margin-left: 24px;
-
-		> .avatar {
-			$size: 32px;
-			display: inline-block;
-			width: $size;
-			height: $size;
-			vertical-align: bottom;
-			margin: 0 8px;
-			pointer-events: none;
-		}
-
-		> .icon {
-			margin-right: 8px;
-		}
-
-		> .title {
-			min-width: 0;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-			line-height: 1.1;
-
-			> .subtitle {
-				opacity: 0.6;
-				font-size: 0.8em;
-				font-weight: normal;
-				white-space: nowrap;
-				overflow: hidden;
-				text-overflow: ellipsis;
-
-				&.activeTab {
-					text-align: center;
-
-					> .chevron {
-						display: inline-block;
-						margin-left: 6px;
-					}
-				}
-			}
-		}
-	}
-
-	> .tabs {
-		margin-left: 16px;
-		font-size: 0.8em;
-		overflow: auto;
-		white-space: nowrap;
-
-		> .tab {
-			display: inline-block;
-			position: relative;
-			padding: 0 10px;
-			height: 100%;
-			font-weight: normal;
-			opacity: 0.7;
-
-			&:hover {
-				opacity: 1;
-			}
-
-			&.active {
-				opacity: 1;
-
-				&:after {
-					content: "";
-					display: block;
-					position: absolute;
-					bottom: 0;
-					left: 0;
-					right: 0;
-					margin: 0 auto;
-					width: 100%;
-					height: 3px;
-					background: var(--accent);
-				}
-			}
-
-			> .icon + .title {
-				margin-left: 8px;
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/page-header.vue
new file mode 100644
index 0000000000..c01631c6a3
--- /dev/null
+++ b/packages/client/src/components/global/page-header.vue
@@ -0,0 +1,300 @@
+<template>
+<div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick">
+	<template v-if="metadata">
+		<div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup">
+			<MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/>
+			<i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+			<div class="title">
+				<MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/>
+				<div v-else-if="metadata.title" class="title">{{ metadata.title }}</div>
+				<div v-if="!narrow && metadata.subtitle" class="subtitle">
+					{{ metadata.subtitle }}
+				</div>
+				<div v-if="narrow && hasTabs" class="subtitle activeTab">
+					{{ tabs.find(tab => tab.active)?.title }}
+					<i class="chevron fas fa-chevron-down"></i>
+				</div>
+			</div>
+		</div>
+		<div v-if="!narrow || hideTitle" class="tabs">
+			<button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
+				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+			</button>
+		</div>
+	</template>
+	<div class="buttons right">
+		<template v-for="action in actions">
+			<button v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, inject } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { scrollToTop } from '@/scripts/scroll';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	tabs?: {
+		title: string;
+		active: boolean;
+		icon?: string;
+		iconOnly?: boolean;
+		onClick: () => void;
+	}[];
+	actions?: {
+		text: string;
+		icon: string;
+		handler: (ev: MouseEvent) => void;
+	}[];
+	thin?: boolean;
+}>();
+
+const metadata = injectPageMetadata();
+
+const hideTitle = inject('shouldOmitHeaderTitle', false);
+const thin_ = props.thin || inject('shouldHeaderThin', false);
+
+const el = $ref<HTMLElement | null>(null);
+const bg = ref(null);
+let narrow = $ref(false);
+const height = ref(0);
+const hasTabs = $computed(() => props.tabs && props.tabs.length > 0);
+const hasActions = $computed(() => props.actions && props.actions.length > 0);
+const show = $computed(() => {
+	return !hideTitle || hasTabs || hasActions;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+	if (!hasTabs) return;
+	if (!narrow) return;
+	ev.preventDefault();
+	ev.stopPropagation();
+	const menu = props.tabs.map(tab => ({
+		text: tab.title,
+		icon: tab.icon,
+		action: tab.onClick,
+	}));
+	popupMenu(menu, ev.currentTarget ?? ev.target);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+	ev.stopPropagation();
+};
+
+const onClick = () => {
+	scrollToTop(el, { behavior: 'smooth' });
+};
+
+const calcBg = () => {
+	const rawBg = metadata?.bg || 'var(--bg)';
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	tinyBg.setAlpha(0.85);
+	bg.value = tinyBg.toRgbString();
+};
+
+let ro: ResizeObserver | null;
+
+onMounted(() => {
+	calcBg();
+	globalEvents.on('themeChanged', calcBg);
+
+	if (el && el.parentElement) {
+		narrow = el.parentElement.offsetWidth < 500;
+		ro = new ResizeObserver((entries, observer) => {
+			if (el.parentElement) {
+				narrow = el.parentElement.offsetWidth < 500;
+			}
+		});
+		ro.observe(el.parentElement);
+	}
+});
+
+onUnmounted(() => {
+	globalEvents.off('themeChanged', calcBg);
+	if (ro) ro.disconnect();
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkb {
+	--height: 60px;
+	display: flex;
+	position: sticky;
+	top: var(--stickyTop, 0);
+	z-index: 1000;
+	width: 100%;
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+	border-bottom: solid 0.5px var(--divider);
+
+	&.thin {
+		--height: 50px;
+
+		> .buttons {
+			> .button {
+				font-size: 0.9em;
+			}
+		}
+	}
+
+	&.slim {
+		text-align: center;
+
+		> .titleContainer {
+			flex: 1;
+			margin: 0 auto;
+			margin-left: var(--height);
+
+			> *:first-child {
+				margin-left: auto;
+			}
+
+			> *:last-child {
+				margin-right: auto;
+			}
+		}
+	}
+
+	> .buttons {
+		--margin: 8px;
+		display: flex;
+    align-items: center;
+		height: var(--height);
+		margin: 0 var(--margin);
+
+		&.right {
+			margin-left: auto;
+		}
+
+		&:empty {
+			width: var(--height);
+		}
+
+		> .button {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			height: calc(var(--height) - (var(--margin) * 2));
+			width: calc(var(--height) - (var(--margin) * 2));
+			box-sizing: border-box;
+			position: relative;
+			border-radius: 5px;
+
+			&:hover {
+				background: rgba(0, 0, 0, 0.05);
+			}
+
+			&.highlighted {
+				color: var(--accent);
+			}
+		}
+
+		> .fullButton {
+			& + .fullButton {
+				margin-left: 12px;
+			}
+		}
+	}
+
+	> .titleContainer {
+		display: flex;
+		align-items: center;
+		max-width: 400px;
+		overflow: auto;
+		white-space: nowrap;
+		text-align: left;
+		font-weight: bold;
+		flex-shrink: 0;
+		margin-left: 24px;
+
+		> .avatar {
+			$size: 32px;
+			display: inline-block;
+			width: $size;
+			height: $size;
+			vertical-align: bottom;
+			margin: 0 8px;
+			pointer-events: none;
+		}
+
+		> .icon {
+			margin-right: 8px;
+		}
+
+		> .title {
+			min-width: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			line-height: 1.1;
+
+			> .subtitle {
+				opacity: 0.6;
+				font-size: 0.8em;
+				font-weight: normal;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+
+				&.activeTab {
+					text-align: center;
+
+					> .chevron {
+						display: inline-block;
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+
+	> .tabs {
+		margin-left: 16px;
+		font-size: 0.8em;
+		overflow: auto;
+		white-space: nowrap;
+
+		> .tab {
+			display: inline-block;
+			position: relative;
+			padding: 0 10px;
+			height: 100%;
+			font-weight: normal;
+			opacity: 0.7;
+
+			&:hover {
+				opacity: 1;
+			}
+
+			&.active {
+				opacity: 1;
+
+				&:after {
+					content: "";
+					display: block;
+					position: absolute;
+					bottom: 0;
+					left: 0;
+					right: 0;
+					margin: 0 auto;
+					width: 100%;
+					height: 3px;
+					background: var(--accent);
+				}
+			}
+
+			> .icon + .title {
+				margin-left: 8px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/components/global/router-view.vue b/packages/client/src/components/global/router-view.vue
new file mode 100644
index 0000000000..393ba30c3d
--- /dev/null
+++ b/packages/client/src/components/global/router-view.vue
@@ -0,0 +1,39 @@
+<template>
+<KeepAlive max="5">
+	<component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/>
+</KeepAlive>
+</template>
+
+<script lang="ts" setup>
+import { inject, nextTick, onMounted, onUnmounted, watch } from 'vue';
+import { Router } from '@/nirax';
+
+const props = defineProps<{
+	router?: Router;
+}>();
+
+const emit = defineEmits<{
+}>();
+
+const router = props.router ?? inject('router');
+
+if (router == null) {
+	throw new Error('no router provided');
+}
+
+let currentPageComponent = $ref(router.getCurrentComponent());
+let currentPageProps = $ref(router.getCurrentProps());
+let key = $ref(router.getCurrentKey());
+
+function onChange({ route, props: newProps, key: newKey }) {
+	currentPageComponent = route.component;
+	currentPageProps = newProps;
+	key = newKey;
+}
+
+router.addListener('change', onChange);
+
+onUnmounted(() => {
+	router.removeListener('change', onChange);
+});
+</script>
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts
index 26bac63245..aa8a591e51 100644
--- a/packages/client/src/components/index.ts
+++ b/packages/client/src/components/index.ts
@@ -10,15 +10,17 @@ import MkEllipsis from './global/ellipsis.vue';
 import MkTime from './global/time.vue';
 import MkUrl from './global/url.vue';
 import I18n from './global/i18n';
+import RouterView from './global/router-view.vue';
 import MkLoading from './global/loading.vue';
 import MkError from './global/error.vue';
 import MkAd from './global/ad.vue';
-import MkHeader from './global/header.vue';
+import MkPageHeader from './global/page-header.vue';
 import MkSpacer from './global/spacer.vue';
 import MkStickyContainer from './global/sticky-container.vue';
 
 export default function(app: App) {
 	app.component('I18n', I18n);
+	app.component('RouterView', RouterView);
 	app.component('Mfm', Mfm);
 	app.component('MkA', MkA);
 	app.component('MkAcct', MkAcct);
@@ -31,7 +33,7 @@ export default function(app: App) {
 	app.component('MkLoading', MkLoading);
 	app.component('MkError', MkError);
 	app.component('MkAd', MkAd);
-	app.component('MkHeader', MkHeader);
+	app.component('MkPageHeader', MkPageHeader);
 	app.component('MkSpacer', MkSpacer);
 	app.component('MkStickyContainer', MkStickyContainer);
 }
@@ -39,6 +41,7 @@ export default function(app: App) {
 declare module '@vue/runtime-core' {
 	export interface GlobalComponents {
 		I18n: typeof I18n;
+		RouterView: typeof RouterView;
 		Mfm: typeof Mfm;
 		MkA: typeof MkA;
 		MkAcct: typeof MkAcct;
@@ -51,7 +54,7 @@ declare module '@vue/runtime-core' {
 		MkLoading: typeof MkLoading;
 		MkError: typeof MkError;
 		MkAd: typeof MkAd;
-		MkHeader: typeof MkHeader;
+		MkPageHeader: typeof MkPageHeader;
 		MkSpacer: typeof MkSpacer;
 		MkStickyContainer: typeof MkStickyContainer;
 	}
diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue
index 21bdb657b7..aef70f113b 100644
--- a/packages/client/src/components/modal-page-window.vue
+++ b/packages/client/src/components/modal-page-window.vue
@@ -1,163 +1,118 @@
 <template>
 <MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
-	<div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
+	<div ref="rootEl" class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
 		<div class="header" @contextmenu="onContextmenu">
 			<button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button>
 			<span v-else style="display: inline-block; width: 20px"></span>
-			<span v-if="pageInfo" class="title">
-				<i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i>
-				<span>{{ pageInfo.title }}</span>
+			<span v-if="pageMetadata?.value" class="title">
+				<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>
+				<span>{{ pageMetadata?.value.title }}</span>
 			</span>
 			<button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button>
 		</div>
 		<div class="body">
 			<MkStickyContainer>
-				<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-				<keep-alive>
-					<component :is="component" v-bind="props" :ref="changePage"/>
-				</keep-alive>
+				<template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template>
+				<RouterView :router="router"/>
 			</MkStickyContainer>
 		</div>
 	</div>
 </MkModal>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ComputedRef, provide } from 'vue';
 import MkModal from '@/components/ui/modal.vue';
-import { popout } from '@/scripts/popout';
+import { popout as _popout } from '@/scripts/popout';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { resolve } from '@/router';
 import { url } from '@/config';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
+import { mainRouter, routes } from '@/router';
+import { i18n } from '@/i18n';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { Router } from '@/nirax';
 
-export default defineComponent({
-	components: {
-		MkModal,
-	},
+const props = defineProps<{
+	initialPath: string;
+}>();
 
-	inject: {
-		sideViewHook: {
-			default: null,
-		},
-	},
-
-	provide() {
-		return {
-			navHook: (path) => {
-				this.navigate(path);
-			},
-			shouldHeaderThin: true,
-		};
-	},
-
-	props: {
-		initialPath: {
-			type: String,
-			required: true,
-		},
-		initialComponent: {
-			type: Object,
-			required: true,
-		},
-		initialProps: {
-			type: Object,
-			required: false,
-			default: () => {},
-		},
-	},
-
-	emits: ['closed'],
-
-	data() {
-		return {
-			width: 860,
-			height: 660,
-			pageInfo: null,
-			path: this.initialPath,
-			component: this.initialComponent,
-			props: this.initialProps,
-			history: [],
-		};
-	},
-
-	computed: {
-		url(): string {
-			return url + this.path;
-		},
+defineEmits<{
+	(ev: 'closed'): void;
+	(ev: 'click'): void;
+}>();
 
-		contextmenu() {
-			return [{
-				type: 'label',
-				text: this.path,
-			}, {
-				icon: 'fas fa-expand-alt',
-				text: this.$ts.showInPage,
-				action: this.expand,
-			}, this.sideViewHook ? {
-				icon: 'fas fa-columns',
-				text: this.$ts.openInSideView,
-				action: () => {
-					this.sideViewHook(this.path);
-					this.$refs.window.close();
-				},
-			} : undefined, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.popout,
-				action: this.popout,
-			}, null, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.openInNewTab,
-				action: () => {
-					window.open(this.url, '_blank');
-					this.$refs.window.close();
-				},
-			}, {
-				icon: 'fas fa-link',
-				text: this.$ts.copyLink,
-				action: () => {
-					copyToClipboard(this.url);
-				},
-			}];
-		},
-	},
+const router = new Router(routes, props.initialPath);
 
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
+router.addListener('push', ctx => {
+	
+});
 
-		navigate(path, record = true) {
-			if (record) this.history.push(this.path);
-			this.path = path;
-			const { component, props } = resolve(path);
-			this.component = component;
-			this.props = props;
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+let rootEl = $ref();
+let modal = $ref<InstanceType<typeof MkModal>>();
+let path = $ref(props.initialPath);
+let width = $ref(860);
+let height = $ref(660);
+const history = [];
+
+provide('router', router);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
+provide('shouldOmitHeaderTitle', true);
+provide('shouldHeaderThin', true);
+
+const pageUrl = $computed(() => url + path);
+const contextmenu = $computed(() => {
+	return [{
+		type: 'label',
+		text: path,
+	}, {
+		icon: 'fas fa-expand-alt',
+		text: i18n.ts.showInPage,
+		action: expand,
+	}, {
+		icon: 'fas fa-external-link-alt',
+		text: i18n.ts.popout,
+		action: popout,
+	}, null, {
+		icon: 'fas fa-external-link-alt',
+		text: i18n.ts.openInNewTab,
+		action: () => {
+			window.open(pageUrl, '_blank');
+			modal.close();
 		},
-
-		back() {
-			this.navigate(this.history.pop(), false);
+	}, {
+		icon: 'fas fa-link',
+		text: i18n.ts.copyLink,
+		action: () => {
+			copyToClipboard(pageUrl);
 		},
+	}];
+});
 
-		expand() {
-			this.$router.push(this.path);
-			this.$refs.window.close();
-		},
+function navigate(path, record = true) {
+	if (record) history.push(router.getCurrentPath());
+	router.push(path);
+}
 
-		popout() {
-			popout(this.path, this.$el);
-			this.$refs.window.close();
-		},
+function back() {
+	navigate(history.pop(), false);
+}
 
-		onContextmenu(ev: MouseEvent) {
-			os.contextMenu(this.contextmenu, ev);
-		},
-	},
-});
+function expand() {
+	mainRouter.push(path);
+	modal.close();
+}
+
+function popout() {
+	_popout(path, rootEl);
+	modal.close();
+}
+
+function onContextmenu(ev: MouseEvent) {
+	os.contextMenu(contextmenu, ev);
+}
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index 9ec1e53c1e..c2c92f541d 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -225,7 +225,7 @@ function undoReact(note): void {
 	});
 }
 
-const currentClipPage = inject<Ref<misskey.entities.Clip>>('currentClipPage');
+const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
 
 function onContextmenu(ev: MouseEvent): void {
 	const isLink = (el: HTMLElement) => {
diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue
index 7455236bad..7de09d3be4 100644
--- a/packages/client/src/components/page-window.vue
+++ b/packages/client/src/components/page-window.vue
@@ -1,186 +1,135 @@
 <template>
-<XWindow ref="window"
+<XWindow
+	ref="windowEl"
 	:initial-width="500"
 	:initial-height="500"
 	:can-resize="true"
 	:close-button="true"
+	:buttons-left="buttonsLeft"
+	:buttons-right="buttonsRight"
 	:contextmenu="contextmenu"
 	@closed="$emit('closed')"
 >
 	<template #header>
-		<template v-if="pageInfo">
-			<i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i>
-			<span>{{ pageInfo.title }}</span>
+		<template v-if="pageMetadata?.value">
+			<i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i>
+			<span>{{ pageMetadata.value.title }}</span>
 		</template>
 	</template>
-	<template #headerLeft>
-		<button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button>
-	</template>
-	<template #headerRight>
-		<button v-tooltip="$ts.showInPage" class="_button" @click="expand()"><i class="fas fa-expand-alt"></i></button>
-		<button v-tooltip="$ts.popout" class="_button" @click="popout()"><i class="fas fa-external-link-alt"></i></button>
-		<button class="_button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
-	</template>
 
-	<div class="yrolvcoq" :style="{ background: pageInfo?.bg }">
-		<MkStickyContainer>
-			<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-			<component :is="component" v-bind="props" :ref="changePage"/>
-		</MkStickyContainer>
+	<div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }">
+		<RouterView :router="router"/>
 	</div>
 </XWindow>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { ComputedRef, inject, provide } from 'vue';
+import RouterView from './global/router-view.vue';
 import XWindow from '@/components/ui/window.vue';
-import { popout } from '@/scripts/popout';
+import { popout as _popout } from '@/scripts/popout';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { resolve } from '@/router';
 import { url } from '@/config';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
+import { mainRouter, routes } from '@/router';
+import { Router } from '@/nirax';
+import { i18n } from '@/i18n';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	initialPath: string;
+}>();
+
+defineEmits<{
+	(ev: 'closed'): void;
+}>();
+
+const router = new Router(routes, props.initialPath);
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+let windowEl = $ref<InstanceType<typeof XWindow>>();
+const history = $ref<string[]>([props.initialPath]);
+const buttonsLeft = $computed(() => {
+	const buttons = [];
+
+	if (history.length > 1) {
+		buttons.push({
+			icon: 'fas fa-arrow-left',
+			onClick: back,
+		});
+	}
+
+	return buttons;
+});
+const buttonsRight = $computed(() => {
+	const buttons = [{
+		icon: 'fas fa-expand-alt',
+		title: i18n.ts.showInPage,
+		onClick: expand,
+	}];
+
+	return buttons;
+});
 
-export default defineComponent({
-	components: {
-		XWindow,
-	},
+router.addListener('push', ctx => {
+	history.push(router.getCurrentPath());
+});
 
-	inject: {
-		sideViewHook: {
-			default: null
-		}
+provide('router', router);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
+provide('shouldOmitHeaderTitle', true);
+provide('shouldHeaderThin', true);
+
+const contextmenu = $computed(() => ([{
+	icon: 'fas fa-expand-alt',
+	text: i18n.ts.showInPage,
+	action: expand,
+}, {
+	icon: 'fas fa-external-link-alt',
+	text: i18n.ts.popout,
+	action: popout,
+}, {
+	icon: 'fas fa-external-link-alt',
+	text: i18n.ts.openInNewTab,
+	action: () => {
+		window.open(url + router.getCurrentPath(), '_blank');
+		windowEl.close();
 	},
-
-	provide() {
-		return {
-			navHook: (path) => {
-				this.navigate(path);
-			},
-			shouldHeaderThin: true,
-		};
+}, {
+	icon: 'fas fa-link',
+	text: i18n.ts.copyLink,
+	action: () => {
+		copyToClipboard(url + router.getCurrentPath());
 	},
+}]));
 
-	props: {
-		initialPath: {
-			type: String,
-			required: true,
-		},
-		initialComponent: {
-			type: Object,
-			required: true,
-		},
-		initialProps: {
-			type: Object,
-			required: false,
-			default: () => {},
-		},
-	},
+function menu(ev) {
+	os.popupMenu(contextmenu, ev.currentTarget ?? ev.target);
+}
 
-	emits: ['closed'],
+function back() {
+	history.pop();
+	router.change(history[history.length - 1]);
+}
 
-	data() {
-		return {
-			pageInfo: null,
-			path: this.initialPath,
-			component: this.initialComponent,
-			props: this.initialProps,
-			history: [],
-		};
-	},
+function close() {
+	windowEl.close();
+}
 
-	computed: {
-		url(): string {
-			return url + this.path;
-		},
-
-		contextmenu() {
-			return [{
-				type: 'label',
-				text: this.path,
-			}, {
-				icon: 'fas fa-expand-alt',
-				text: this.$ts.showInPage,
-				action: this.expand
-			}, this.sideViewHook ? {
-				icon: 'fas fa-columns',
-				text: this.$ts.openInSideView,
-				action: () => {
-					this.sideViewHook(this.path);
-					this.$refs.window.close();
-				}
-			} : undefined, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.popout,
-				action: this.popout
-			}, null, {
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.openInNewTab,
-				action: () => {
-					window.open(this.url, '_blank');
-					this.$refs.window.close();
-				}
-			}, {
-				icon: 'fas fa-link',
-				text: this.$ts.copyLink,
-				action: () => {
-					copyToClipboard(this.url);
-				}
-			}];
-		},
-	},
+function expand() {
+	mainRouter.push(router.getCurrentPath());
+	windowEl.close();
+}
 
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
-
-		navigate(path, record = true) {
-			if (record) this.history.push(this.path);
-			this.path = path;
-			const { component, props } = resolve(path);
-			this.component = component;
-			this.props = props;
-		},
-
-		menu(ev) {
-			os.popupMenu([{
-				icon: 'fas fa-external-link-alt',
-				text: this.$ts.openInNewTab,
-				action: () => {
-					window.open(this.url, '_blank');
-					this.$refs.window.close();
-				}
-			}, {
-				icon: 'fas fa-link',
-				text: this.$ts.copyLink,
-				action: () => {
-					copyToClipboard(this.url);
-				}
-			}], ev.currentTarget ?? ev.target);
-		},
-
-		back() {
-			this.navigate(this.history.pop(), false);
-		},
-
-		close() {
-			this.$refs.window.close();
-		},
-
-		expand() {
-			this.$router.push(this.path);
-			this.$refs.window.close();
-		},
-
-		popout() {
-			popout(this.path, this.$el);
-			this.$refs.window.close();
-		},
-	},
+function popout() {
+	_popout(router.getCurrentPath(), windowEl.$el);
+	windowEl.close();
+}
+
+defineExpose({
+	close,
 });
 </script>
 
diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue
index 2066cf579d..3cd4378f03 100644
--- a/packages/client/src/components/ui/window.vue
+++ b/packages/client/src/components/ui/window.vue
@@ -4,14 +4,14 @@
 		<div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
 			<div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
 				<span class="left">
-					<slot name="headerLeft"></slot>
+					<button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
 				</span>
 				<span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
 					<slot name="header"></slot>
 				</span>
 				<span class="right">
-					<slot name="headerRight"></slot>
-					<button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button>
+					<button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
+					<button v-if="closeButton" class="button _button" @click="close()"><i class="fas fa-times"></i></button>
 				</span>
 			</div>
 			<div v-if="padding" class="body">
@@ -46,41 +46,41 @@ const minHeight = 50;
 const minWidth = 250;
 
 function dragListen(fn) {
-	window.addEventListener('mousemove',  fn);
-	window.addEventListener('touchmove',  fn);
+	window.addEventListener('mousemove', fn);
+	window.addEventListener('touchmove', fn);
 	window.addEventListener('mouseleave', dragClear.bind(null, fn));
-	window.addEventListener('mouseup',    dragClear.bind(null, fn));
-	window.addEventListener('touchend',   dragClear.bind(null, fn));
+	window.addEventListener('mouseup', dragClear.bind(null, fn));
+	window.addEventListener('touchend', dragClear.bind(null, fn));
 }
 
 function dragClear(fn) {
-	window.removeEventListener('mousemove',  fn);
-	window.removeEventListener('touchmove',  fn);
+	window.removeEventListener('mousemove', fn);
+	window.removeEventListener('touchmove', fn);
 	window.removeEventListener('mouseleave', dragClear);
-	window.removeEventListener('mouseup',    dragClear);
-	window.removeEventListener('touchend',   dragClear);
+	window.removeEventListener('mouseup', dragClear);
+	window.removeEventListener('touchend', dragClear);
 }
 
 export default defineComponent({
 	provide: {
-		inWindow: true
+		inWindow: true,
 	},
 
 	props: {
 		padding: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		initialWidth: {
 			type: Number,
 			required: false,
-			default: 400
+			default: 400,
 		},
 		initialHeight: {
 			type: Number,
 			required: false,
-			default: null
+			default: null,
 		},
 		canResize: {
 			type: Boolean,
@@ -105,7 +105,17 @@ export default defineComponent({
 		contextmenu: {
 			type: Array,
 			required: false,
-		}
+		},
+		buttonsLeft: {
+			type: Array,
+			required: false,
+			default: [],
+		},
+		buttonsRight: {
+			type: Array,
+			required: false,
+			default: [],
+		},
 	},
 
 	emits: ['closed'],
@@ -162,7 +172,10 @@ export default defineComponent({
 			this.top();
 		},
 
-		onHeaderMousedown(evt) {
+		onHeaderMousedown(evt: MouseEvent) {
+			// 右クリックはコンテキストメニューを開こうとした可能性が高いため無視
+			if (evt.button === 2) return;
+
 			const main = this.$el as any;
 
 			if (!contains(main, document.activeElement)) main.focus();
@@ -356,12 +369,12 @@ export default defineComponent({
 			const browserHeight = window.innerHeight;
 			const windowWidth = main.offsetWidth;
 			const windowHeight = main.offsetHeight;
-			if (position.left < 0) main.style.left = 0;     // 左はみ出し
-			if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px';  // 下はみ出し
-			if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px';    // 右はみ出し
-			if (position.top < 0) main.style.top = 0;       // 上はみ出し
-		}
-	}
+			if (position.left < 0) main.style.left = 0; // 左はみ出し
+			if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し
+			if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し
+			if (position.top < 0) main.style.top = 0; // 上はみ出し
+		},
+	},
 });
 </script>
 
@@ -404,17 +417,25 @@ export default defineComponent({
 			border-bottom: solid 1px var(--divider);
 
 			> .left, > .right {
-				> ::v-deep(button) {
+				> .button {
 					height: var(--height);
 					width: var(--height);
 
 					&:hover {
 						color: var(--fgHighlighted);
 					}
+
+					&.highlighted {
+						color: var(--accent);
+					}
 				}
 			}
 
 			> .left {
+				margin-right: 16px;
+			}
+
+			> .right {
 				min-width: 16px;
 			}
 
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts
index bb6176e409..a11dd2d050 100644
--- a/packages/client/src/init.ts
+++ b/packages/client/src/init.ts
@@ -21,7 +21,6 @@ import widgets from '@/widgets';
 import directives from '@/directives';
 import components from '@/components';
 import { version, ui, lang, host } from '@/config';
-import { router } from '@/router';
 import { applyTheme } from '@/scripts/theme';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
 import { i18n } from '@/i18n';
@@ -170,11 +169,10 @@ fetchInstanceMetaPromise.then(() => {
 
 const app = createApp(
 	window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
-	!$i                               ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
-	ui === 'deck'                     ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
-	ui === 'desktop'                  ? defineAsyncComponent(() => import('@/ui/desktop.vue')) :
-	ui === 'classic'                  ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
-	defineAsyncComponent(() => import('@/ui/universal.vue'))
+	!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
+	ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
+	ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
+	defineAsyncComponent(() => import('@/ui/universal.vue')),
 );
 
 if (_DEV_) {
@@ -189,14 +187,10 @@ app.config.globalProperties = {
 	$ts: i18n.ts,
 };
 
-app.use(router);
-
 widgets(app);
 directives(app);
 components(app);
 
-await router.isReady();
-
 const splash = document.getElementById('splash');
 // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
 if (splash) splash.addEventListener('transitionend', () => {
diff --git a/packages/client/src/menu.ts b/packages/client/src/menu.ts
index ebc7898101..5e281f4ea1 100644
--- a/packages/client/src/menu.ts
+++ b/packages/client/src/menu.ts
@@ -1,11 +1,11 @@
 import { computed, ref, reactive } from 'vue';
+import { $i } from './account';
+import { mainRouter } from '@/router';
 import { search } from '@/scripts/search';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
 import { ui } from '@/config';
-import { $i } from './account';
 import { unisonReload } from '@/scripts/unison-reload';
-import { router } from './router';
 
 export const menuDef = reactive({
 	notifications: {
@@ -60,16 +60,16 @@ export const menuDef = reactive({
 		title: 'lists',
 		icon: 'fas fa-list-ul',
 		show: computed(() => $i != null),
-		active: computed(() => router.currentRoute.value.path.startsWith('/timeline/list/') || router.currentRoute.value.path === '/my/lists' || router.currentRoute.value.path.startsWith('/my/lists/')),
+		active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/list/') || mainRouter.currentRoute.value.path === '/my/lists' || mainRouter.currentRoute.value.path.startsWith('/my/lists/')),
 		action: (ev) => {
 			const items = ref([{
-				type: 'pending'
+				type: 'pending',
 			}]);
 			os.api('users/lists/list').then(lists => {
 				const _items = [...lists.map(list => ({
 					type: 'link',
 					text: list.name,
-					to: `/timeline/list/${list.id}`
+					to: `/timeline/list/${list.id}`,
 				})), null, {
 					type: 'link',
 					to: '/my/lists',
@@ -91,16 +91,16 @@ export const menuDef = reactive({
 		title: 'antennas',
 		icon: 'fas fa-satellite',
 		show: computed(() => $i != null),
-		active: computed(() => router.currentRoute.value.path.startsWith('/timeline/antenna/') || router.currentRoute.value.path === '/my/antennas' || router.currentRoute.value.path.startsWith('/my/antennas/')),
+		active: computed(() => mainRouter.currentRoute.value.path.startsWith('/timeline/antenna/') || mainRouter.currentRoute.value.path === '/my/antennas' || mainRouter.currentRoute.value.path.startsWith('/my/antennas/')),
 		action: (ev) => {
 			const items = ref([{
-				type: 'pending'
+				type: 'pending',
 			}]);
 			os.api('antennas/list').then(antennas => {
 				const _items = [...antennas.map(antenna => ({
 					type: 'link',
 					text: antenna.name,
-					to: `/timeline/antenna/${antenna.id}`
+					to: `/timeline/antenna/${antenna.id}`,
 				})), null, {
 					type: 'link',
 					to: '/my/antennas',
@@ -178,29 +178,22 @@ export const menuDef = reactive({
 				action: () => {
 					localStorage.setItem('ui', 'default');
 					unisonReload();
-				}
+				},
 			}, {
 				text: i18n.ts.deck,
 				active: ui === 'deck',
 				action: () => {
 					localStorage.setItem('ui', 'deck');
 					unisonReload();
-				}
+				},
 			}, {
 				text: i18n.ts.classic,
 				active: ui === 'classic',
 				action: () => {
 					localStorage.setItem('ui', 'classic');
 					unisonReload();
-				}
-			}, /*{
-				text: i18n.ts.desktop + ' (β)',
-				active: ui === 'desktop',
-				action: () => {
-					localStorage.setItem('ui', 'desktop');
-					unisonReload();
-				}
-			}*/], ev.currentTarget ?? ev.target);
+				},
+			}], ev.currentTarget ?? ev.target);
 		},
 	},
 });
diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts
new file mode 100644
index 0000000000..6840483c21
--- /dev/null
+++ b/packages/client/src/nirax.ts
@@ -0,0 +1,200 @@
+import { EventEmitter } from 'eventemitter3';
+import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue';
+
+type RouteDef = {
+	path: string;
+	component: Component;
+	query?: Record<string, string>;
+	name?: string;
+	globalCacheKey?: string;
+};
+
+type ParsedPath = (string | {
+	name: string;
+	startsWith?: string;
+	wildcard?: boolean;
+	optional?: boolean;
+})[];
+
+function parsePath(path: string): ParsedPath {
+	const res = [] as ParsedPath;
+
+	path = path.substring(1);
+
+	for (const part of path.split('/')) {
+		if (part.includes(':')) {
+			const prefix = part.substring(0, part.indexOf(':'));
+			const placeholder = part.substring(part.indexOf(':') + 1);
+			const wildcard = placeholder.includes('(*)');
+			const optional = placeholder.endsWith('?');
+			res.push({
+				name: placeholder.replace('(*)', '').replace('?', ''),
+				startsWith: prefix !== '' ? prefix : undefined,
+				wildcard,
+				optional,
+			});
+		} else {
+			res.push(part);
+		}
+	}
+
+	return res;
+}
+
+export class Router extends EventEmitter<{
+	change: (ctx: {
+		beforePath: string;
+		path: string;
+		route: RouteDef | null;
+		props: Map<string, string> | null;
+		key: string;
+	}) => void;
+	push: (ctx: {
+		beforePath: string;
+		path: string;
+		route: RouteDef | null;
+		props: Map<string, string> | null;
+		key: string;
+	}) => void;
+}> {
+	private routes: RouteDef[];
+	private currentPath: string;
+	private currentComponent: Component | null = null;
+	private currentProps: Map<string, string> | null = null;
+	private currentKey = Date.now().toString();
+
+	public currentRoute: ShallowRef<RouteDef | null> = shallowRef(null);
+
+	constructor(routes: Router['routes'], currentPath: Router['currentPath']) {
+		super();
+
+		this.routes = routes;
+		this.currentPath = currentPath;
+		this.navigate(currentPath, null, true);
+	}
+
+	public resolve(path: string): { route: RouteDef; props: Map<string, string>; } | null {
+		let queryString: string | null = null;
+		if (path[0] === '/') path = path.substring(1);
+		if (path.includes('?')) {
+			queryString = path.substring(path.indexOf('?') + 1);
+			path = path.substring(0, path.indexOf('?'));
+		}
+
+		if (_DEV_) console.log('Routing: ', path, queryString);
+
+		forEachRouteLoop:
+		for (const route of this.routes) {
+			let parts = path.split('/');
+			const props = new Map<string, string>();
+
+			pathMatchLoop:
+			for (const p of parsePath(route.path)) {
+				if (typeof p === 'string') {
+					if (p === parts[0]) {
+						parts.shift();
+					} else {
+						continue forEachRouteLoop;
+					}
+				} else {
+					if (parts[0] == null && !p.optional) {
+						continue forEachRouteLoop;
+					}
+					if (p.wildcard) {
+						if (parts.length !== 0) {
+							props.set(p.name, parts.join('/'));
+							parts = [];
+						}
+						break pathMatchLoop;
+					} else {
+						if (p.startsWith && (parts[0] == null || !parts[0].startsWith(p.startsWith))) continue forEachRouteLoop;
+
+						props.set(p.name, parts[0]);
+						parts.shift();
+					}
+				}
+			}
+
+			if (parts.length !== 0) continue forEachRouteLoop;
+
+			if (route.query != null && queryString != null) {
+				const queryObject = [...new URLSearchParams(queryString).entries()]
+					.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
+
+				for (const q in route.query) {
+					const as = route.query[q];
+					if (queryObject[q]) {
+						props.set(as, queryObject[q]);
+					}
+				}
+			}
+			return {
+				route,
+				props,
+			};
+		}
+
+		return null;
+	}
+
+	private navigate(path: string, key: string | null | undefined, initial = false) {
+		const beforePath = this.currentPath;
+		const beforeRoute = this.currentRoute.value;
+		this.currentPath = path;
+
+		const res = this.resolve(this.currentPath);
+
+		if (res == null) {
+			throw new Error('no route found for: ' + path);
+		}
+
+		const isSamePath = beforePath === path;
+		if (isSamePath && key == null) key = this.currentKey;
+		this.currentComponent = res.route.component;
+		this.currentProps = res.props;
+		this.currentRoute.value = res.route;
+		this.currentKey = this.currentRoute.value.globalCacheKey ?? key ?? Date.now().toString();
+
+		if (!initial) {
+			this.emit('change', {
+				beforePath,
+				path,
+				route: this.currentRoute.value,
+				props: this.currentProps,
+				key: this.currentKey,
+			});
+		}
+	}
+
+	public getCurrentComponent() {
+		return this.currentComponent;
+	}
+
+	public getCurrentProps() {
+		return this.currentProps;
+	}
+
+	public getCurrentPath() {
+		return this.currentPath;
+	}
+
+	public getCurrentKey() {
+		return this.currentKey;
+	}
+
+	public push(path: string) {
+		const beforePath = this.currentPath;
+		this.navigate(path, null);
+		this.emit('push', {
+			beforePath,
+			path,
+			route: this.currentRoute.value,
+			props: this.currentProps,
+			key: this.currentKey,
+		});
+	}
+
+	public change(path: string, key?: string | null) {
+		this.navigate(path, key);
+	}
+}
diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts
index 14860465fa..e823d3719c 100644
--- a/packages/client/src/os.ts
+++ b/packages/client/src/os.ts
@@ -8,7 +8,6 @@ import { apiUrl, url } from '@/config';
 import MkPostFormDialog from '@/components/post-form-dialog.vue';
 import MkWaitingDialog from '@/components/waiting-dialog.vue';
 import { MenuItem } from '@/types/menu';
-import { resolve } from '@/router';
 import { $i } from '@/account';
 
 export const pendingApiRequestsCount = ref(0);
@@ -155,20 +154,14 @@ export async function popup(component: Component, props: Record<string, any>, ev
 }
 
 export function pageWindow(path: string) {
-	const { component, props } = resolve(path);
 	popup(defineAsyncComponent(() => import('@/components/page-window.vue')), {
 		initialPath: path,
-		initialComponent: markRaw(component),
-		initialProps: props,
 	}, {}, 'closed');
 }
 
 export function modalPageWindow(path: string) {
-	const { component, props } = resolve(path);
 	popup(defineAsyncComponent(() => import('@/components/modal-page-window.vue')), {
 		initialPath: path,
-		initialComponent: markRaw(component),
-		initialProps: props,
 	}, {}, 'closed');
 }
 
diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue
index 4cfe2e255c..6ac1f4297a 100644
--- a/packages/client/src/pages/_error_.vue
+++ b/packages/client/src/pages/_error_.vue
@@ -21,11 +21,11 @@
 import { } from 'vue';
 import * as misskey from 'misskey-js';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
 import { version } from '@/config';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = withDefaults(defineProps<{
 	error?: Error;
@@ -52,11 +52,13 @@ function reload() {
 	unisonReload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.error,
-		icon: 'fas fa-exclamation-triangle',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.error,
+	icon: 'fas fa-exclamation-triangle',
 });
 </script>
 
diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue
index 691bc4f07b..ba85860cda 100644
--- a/packages/client/src/pages/about-misskey.vue
+++ b/packages/client/src/pages/about-misskey.vue
@@ -1,62 +1,65 @@
 <template>
-<div style="overflow: clip;">
-	<MkSpacer :content-max="600" :margin-min="20">
-		<div class="_formRoot znqjceqz">
-			<div id="debug"></div>
-			<div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
-				<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
-				<div class="misskey">Misskey</div>
-				<div class="version">v{{ version }}</div>
-				<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
-			</div>
-			<div class="_formBlock" style="text-align: center;">
-				{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
-			</div>
-			<div class="_formBlock" style="text-align: center;">
-				<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
-			</div>
-			<FormSection>
-				<div class="_formLinks">
-					<FormLink to="https://github.com/misskey-dev/misskey" external>
-						<template #icon><i class="fas fa-code"></i></template>
-						{{ i18n.ts._aboutMisskey.source }}
-						<template #suffix>GitHub</template>
-					</FormLink>
-					<FormLink to="https://crowdin.com/project/misskey" external>
-						<template #icon><i class="fas fa-language"></i></template>
-						{{ i18n.ts._aboutMisskey.translation }}
-						<template #suffix>Crowdin</template>
-					</FormLink>
-					<FormLink to="https://www.patreon.com/syuilo" external>
-						<template #icon><i class="fas fa-hand-holding-medical"></i></template>
-						{{ i18n.ts._aboutMisskey.donate }}
-						<template #suffix>Patreon</template>
-					</FormLink>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div style="overflow: clip;">
+		<MkSpacer :content-max="600" :margin-min="20">
+			<div class="_formRoot znqjceqz">
+				<div id="debug"></div>
+				<div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }">
+					<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
+					<div class="misskey">Misskey</div>
+					<div class="version">v{{ version }}</div>
+					<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
+				</div>
+				<div class="_formBlock" style="text-align: center;">
+					{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
 				</div>
-			</FormSection>
-			<FormSection>
-				<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
-				<div class="_formLinks">
-					<FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
-					<FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
-					<FormLink to="https://github.com/mei23" external>@mei23</FormLink>
-					<FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
-					<FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
-					<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
-					<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
-					<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
-					<FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+				<div class="_formBlock" style="text-align: center;">
+					<MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton>
 				</div>
-				<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
-			</FormSection>
-			<FormSection>
-				<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
-				<div v-for="patron in patrons" :key="patron">{{ patron }}</div>
-				<template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
-			</FormSection>
-		</div>
-	</MkSpacer>
-</div>
+				<FormSection>
+					<div class="_formLinks">
+						<FormLink to="https://github.com/misskey-dev/misskey" external>
+							<template #icon><i class="fas fa-code"></i></template>
+							{{ i18n.ts._aboutMisskey.source }}
+							<template #suffix>GitHub</template>
+						</FormLink>
+						<FormLink to="https://crowdin.com/project/misskey" external>
+							<template #icon><i class="fas fa-language"></i></template>
+							{{ i18n.ts._aboutMisskey.translation }}
+							<template #suffix>Crowdin</template>
+						</FormLink>
+						<FormLink to="https://www.patreon.com/syuilo" external>
+							<template #icon><i class="fas fa-hand-holding-medical"></i></template>
+							{{ i18n.ts._aboutMisskey.donate }}
+							<template #suffix>Patreon</template>
+						</FormLink>
+					</div>
+				</FormSection>
+				<FormSection>
+					<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
+					<div class="_formLinks">
+						<FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
+						<FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
+						<FormLink to="https://github.com/mei23" external>@mei23</FormLink>
+						<FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
+						<FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
+						<FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
+						<FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
+						<FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
+						<FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+					</div>
+					<template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
+				</FormSection>
+				<FormSection>
+					<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
+					<div v-for="patron in patrons" :key="patron">{{ patron }}</div>
+					<template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
+				</FormSection>
+			</div>
+		</MkSpacer>
+	</div>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -67,10 +70,10 @@ import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkLink from '@/components/link.vue';
 import { physics } from '@/scripts/physics';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { defaultStore } from '@/store';
 import * as os from '@/os';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const patrons = [
 	'まっちゃとーにゅ',
@@ -194,12 +197,14 @@ onBeforeUnmount(() => {
 	}
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.aboutMisskey,
-		icon: null,
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.aboutMisskey,
+	icon: null,
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
index 6cc2e387ec..20497c86fc 100644
--- a/packages/client/src/pages/about.vue
+++ b/packages/client/src/pages/about.vue
@@ -1,78 +1,81 @@
 <template>
-<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
-	<div class="_formRoot">
-		<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
-			<div class="content">
-				<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
-				<div class="name">
-					<b>{{ $instance.name || host }}</b>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
+		<div class="_formRoot">
+			<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+				<div class="content">
+					<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+					<div class="name">
+						<b>{{ $instance.name || host }}</b>
+					</div>
 				</div>
 			</div>
-		</div>
-
-		<MkKeyValue class="_formBlock">
-			<template #key>{{ $ts.description }}</template>
-			<template #value>{{ $instance.description }}</template>
-		</MkKeyValue>
 
-		<FormSection>
-			<MkKeyValue class="_formBlock" :copy="version">
-				<template #key>Misskey</template>
-				<template #value>{{ version }}</template>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ $ts.description }}</template>
+				<template #value>{{ $instance.description }}</template>
 			</MkKeyValue>
-			<FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
-		</FormSection>
 
-		<FormSection>
-			<FormSplit>
-				<MkKeyValue class="_formBlock">
-					<template #key>{{ $ts.administrator }}</template>
-					<template #value>{{ $instance.maintainerName }}</template>
-				</MkKeyValue>
-				<MkKeyValue class="_formBlock">
-					<template #key>{{ $ts.contact }}</template>
-					<template #value>{{ $instance.maintainerEmail }}</template>
+			<FormSection>
+				<MkKeyValue class="_formBlock" :copy="version">
+					<template #key>Misskey</template>
+					<template #value>{{ version }}</template>
 				</MkKeyValue>
-			</FormSplit>
-			<FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink>
-		</FormSection>
+				<FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
+			</FormSection>
 
-		<FormSuspense :p="initStats">
 			<FormSection>
-				<template #label>{{ $ts.statistics }}</template>
 				<FormSplit>
 					<MkKeyValue class="_formBlock">
-						<template #key>{{ $ts.users }}</template>
-						<template #value>{{ number(stats.originalUsersCount) }}</template>
+						<template #key>{{ $ts.administrator }}</template>
+						<template #value>{{ $instance.maintainerName }}</template>
 					</MkKeyValue>
 					<MkKeyValue class="_formBlock">
-						<template #key>{{ $ts.notes }}</template>
-						<template #value>{{ number(stats.originalNotesCount) }}</template>
+						<template #key>{{ $ts.contact }}</template>
+						<template #value>{{ $instance.maintainerEmail }}</template>
 					</MkKeyValue>
 				</FormSplit>
+				<FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink>
 			</FormSection>
-		</FormSuspense>
 
-		<FormSection>
-			<template #label>Well-known resources</template>
-			<div class="_formLinks">
-				<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
-				<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
-				<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
-				<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
-				<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
-			</div>
-		</FormSection>
-	</div>
-</MkSpacer>
-<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20">
-	<MkInstanceStats :chart-limit="500" :detailed="true"/>
-</MkSpacer>
+			<FormSuspense :p="initStats">
+				<FormSection>
+					<template #label>{{ $ts.statistics }}</template>
+					<FormSplit>
+						<MkKeyValue class="_formBlock">
+							<template #key>{{ $ts.users }}</template>
+							<template #value>{{ number(stats.originalUsersCount) }}</template>
+						</MkKeyValue>
+						<MkKeyValue class="_formBlock">
+							<template #key>{{ $ts.notes }}</template>
+							<template #value>{{ number(stats.originalNotesCount) }}</template>
+						</MkKeyValue>
+					</FormSplit>
+				</FormSection>
+			</FormSuspense>
+
+			<FormSection>
+				<template #label>Well-known resources</template>
+				<div class="_formLinks">
+					<FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
+					<FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
+					<FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
+					<FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
+					<FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
+				</div>
+			</FormSection>
+		</div>
+	</MkSpacer>
+	<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20">
+		<MkInstanceStats :chart-limit="500" :detailed="true"/>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
-import { version, instanceName } from '@/config';
+import { version, instanceName , host } from '@/config';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
 import FormSuspense from '@/components/form/suspense.vue';
@@ -81,9 +84,8 @@ import MkKeyValue from '@/components/key-value.vue';
 import MkInstanceStats from '@/components/instance-stats.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
-import * as symbols from '@/symbols';
-import { host } from '@/config';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let stats = $ref(null);
 let tab = $ref('overview');
@@ -93,23 +95,24 @@ const initStats = () => os.api('stats', {
 	stats = res;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.instanceInfo,
-		icon: 'fas fa-info-circle',
-		bg: 'var(--bg)',
-		tabs: [{
-			active: tab === 'overview',
-			title: i18n.ts.overview,
-			onClick: () => { tab = 'overview'; },
-		}, {
-			active: tab === 'charts',
-			title: i18n.ts.charts,
-			icon: 'fas fa-chart-bar',
-			onClick: () => { tab = 'charts'; },
-		},],
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'overview',
+	title: i18n.ts.overview,
+	onClick: () => { tab = 'overview'; },
+}, {
+	active: tab === 'charts',
+	title: i18n.ts.charts,
+	icon: 'fas fa-chart-bar',
+	onClick: () => { tab = 'charts'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.instanceInfo,
+	icon: 'fas fa-info-circle',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin-file.vue b/packages/client/src/pages/admin-file.vue
index b0e879b8f3..67dcd88bc4 100644
--- a/packages/client/src/pages/admin-file.vue
+++ b/packages/client/src/pages/admin-file.vue
@@ -1,30 +1,33 @@
 <template>
-<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
-	<div v-if="file" class="cxqhhsmd _formRoot">
-		<div class="_formBlock">
-			<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
-			<div class="info">
-				<span style="margin-right: 1em;">{{ file.type }}</span>
-				<span>{{ bytes(file.size) }}</span>
-				<MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
+		<div v-if="file" class="cxqhhsmd _formRoot">
+			<div class="_formBlock">
+				<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+				<div class="info">
+					<span style="margin-right: 1em;">{{ file.type }}</span>
+					<span>{{ bytes(file.size) }}</span>
+					<MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
+				</div>
+			</div>
+			<div class="_formBlock">
+				<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
+			</div>
+			<div class="_formBlock">
+				<MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
+			</div>
+			<div class="_formBlock">
+				<MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+			</div>
+			<div v-if="info" class="_formBlock">
+				<details class="_content rawdata">
+					<pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
+				</details>
 			</div>
 		</div>
-		<div class="_formBlock">
-			<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
-		</div>
-		<div class="_formBlock">
-			<MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
-		</div>
-		<div class="_formBlock">
-			<MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
-		</div>
-		<div v-if="info" class="_formBlock">
-			<details class="_content rawdata">
-				<pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
-			</details>
-		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -35,7 +38,7 @@ import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
 import bytes from '@/filters/bytes';
 import * as os from '@/os';
 import { i18n } from '@/i18n';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let file: any = $ref(null);
 let info: any = $ref(null);
@@ -74,13 +77,15 @@ async function toggleIsSensitive(v) {
 	isSensitive = v;
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
-		icon: 'fas fa-file',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
+	icon: 'fas fa-file',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue
new file mode 100644
index 0000000000..9e11d065d9
--- /dev/null
+++ b/packages/client/src/pages/admin/_header_.vue
@@ -0,0 +1,249 @@
+<template>
+<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick">
+	<template v-if="metadata">
+		<div class="titleContainer" @click="showTabsPopup">
+			<i v-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+			<div class="title">
+				<div class="title">{{ metadata.title }}</div>
+			</div>
+		</div>
+		<div class="tabs">
+			<button v-for="tab in tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick">
+				<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+				<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+			</button>
+		</div>
+	</template>
+	<div class="buttons right">
+		<template v-if="actions">
+			<template v-for="action in actions">
+				<MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+				<button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+			</template>
+		</template>
+	</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, inject } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { url } from '@/config';
+import { scrollToTop } from '@/scripts/scroll';
+import MkButton from '@/components/ui/button.vue';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+import { injectPageMetadata, PageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	tabs?: {
+		title: string;
+		active: boolean;
+		icon?: string;
+		iconOnly?: boolean;
+		onClick: () => void;
+	}[];
+	actions?: {
+		text: string;
+		icon: string;
+		asFullButton?: boolean;
+		handler: (ev: MouseEvent) => void;
+	}[];
+	thin?: boolean;
+}>();
+
+const metadata = injectPageMetadata();
+
+const el = ref<HTMLElement>(null);
+const bg = ref(null);
+const height = ref(0);
+const hasTabs = computed(() => {
+	return props.tabs && props.tabs.length > 0;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+	if (!hasTabs.value) return;
+	if (!narrow.value) return;
+	ev.preventDefault();
+	ev.stopPropagation();
+	const menu = props.tabs.map(tab => ({
+		text: tab.title,
+		icon: tab.icon,
+		action: tab.onClick,
+	}));
+	popupMenu(menu, ev.currentTarget ?? ev.target);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+	ev.stopPropagation();
+};
+
+const onClick = () => {
+	scrollToTop(el.value, { behavior: 'smooth' });
+};
+
+const calcBg = () => {
+	const rawBg = metadata?.bg || 'var(--bg)';
+	const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+	tinyBg.setAlpha(0.85);
+	bg.value = tinyBg.toRgbString();
+};
+
+onMounted(() => {
+	calcBg();
+	globalEvents.on('themeChanged', calcBg);
+});
+
+onUnmounted(() => {
+	globalEvents.off('themeChanged', calcBg);
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkc {
+	--height: 60px;
+	display: flex;
+	position: sticky;
+	top: var(--stickyTop, 0);
+	z-index: 1000;
+	width: 100%;
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+
+	> .buttons {
+		--margin: 8px;
+		display: flex;
+    align-items: center;
+		height: var(--height);
+		margin: 0 var(--margin);
+
+		&.right {
+			margin-left: auto;
+		}
+
+		&:empty {
+			width: var(--height);
+		}
+
+		> .button {
+			display: flex;
+			align-items: center;
+			justify-content: center;
+			height: calc(var(--height) - (var(--margin) * 2));
+			width: calc(var(--height) - (var(--margin) * 2));
+			box-sizing: border-box;
+			position: relative;
+			border-radius: 5px;
+
+			&:hover {
+				background: rgba(0, 0, 0, 0.05);
+			}
+
+			&.highlighted {
+				color: var(--accent);
+			}
+		}
+
+		> .fullButton {
+			& + .fullButton {
+				margin-left: 12px;
+			}
+		}
+	}
+
+	> .titleContainer {
+		display: flex;
+		align-items: center;
+		max-width: 400px;
+		overflow: auto;
+		white-space: nowrap;
+		text-align: left;
+		font-weight: bold;
+		flex-shrink: 0;
+		margin-left: 24px;
+
+		> .avatar {
+			$size: 32px;
+			display: inline-block;
+			width: $size;
+			height: $size;
+			vertical-align: bottom;
+			margin: 0 8px;
+			pointer-events: none;
+		}
+
+		> .icon {
+			margin-right: 8px;
+		}
+
+		> .title {
+			min-width: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			line-height: 1.1;
+
+			> .subtitle {
+				opacity: 0.6;
+				font-size: 0.8em;
+				font-weight: normal;
+				white-space: nowrap;
+				overflow: hidden;
+				text-overflow: ellipsis;
+
+				&.activeTab {
+					text-align: center;
+
+					> .chevron {
+						display: inline-block;
+						margin-left: 6px;
+					}
+				}
+			}
+		}
+	}
+
+	> .tabs {
+		margin-left: 16px;
+		font-size: 0.8em;
+		overflow: auto;
+		white-space: nowrap;
+
+		> .tab {
+			display: inline-block;
+			position: relative;
+			padding: 0 10px;
+			height: 100%;
+			font-weight: normal;
+			opacity: 0.7;
+
+			&:hover {
+				opacity: 1;
+			}
+
+			&.active {
+				opacity: 1;
+
+				&:after {
+					content: "";
+					display: block;
+					position: absolute;
+					bottom: 0;
+					left: 0;
+					right: 0;
+					margin: 0 auto;
+					width: 100%;
+					height: 3px;
+					background: var(--accent);
+				}
+			}
+
+			> .icon + .title {
+				margin-left: 8px;
+			}
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
index e1d0361c0b..2b6dadf7c6 100644
--- a/packages/client/src/pages/admin/abuses.vue
+++ b/packages/client/src/pages/admin/abuses.vue
@@ -1,28 +1,31 @@
 <template>
-<div class="lcixvhis">
-	<div class="_section reports">
-		<div class="_content">
-			<div class="inputs" style="display: flex;">
-				<MkSelect v-model="state" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.state }}</template>
-					<option value="all">{{ $ts.all }}</option>
-					<option value="unresolved">{{ $ts.unresolved }}</option>
-					<option value="resolved">{{ $ts.resolved }}</option>
-				</MkSelect>
-				<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.reporteeOrigin }}</template>
-					<option value="combined">{{ $ts.all }}</option>
-					<option value="local">{{ $ts.local }}</option>
-					<option value="remote">{{ $ts.remote }}</option>
-				</MkSelect>
-				<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
-					<template #label>{{ $ts.reporterOrigin }}</template>
-					<option value="combined">{{ $ts.all }}</option>
-					<option value="local">{{ $ts.local }}</option>
-					<option value="remote">{{ $ts.remote }}</option>
-				</MkSelect>
-			</div>
-			<!-- TODO
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="lcixvhis">
+			<div class="_section reports">
+				<div class="_content">
+					<div class="inputs" style="display: flex;">
+						<MkSelect v-model="state" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.state }}</template>
+							<option value="all">{{ $ts.all }}</option>
+							<option value="unresolved">{{ $ts.unresolved }}</option>
+							<option value="resolved">{{ $ts.resolved }}</option>
+						</MkSelect>
+						<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.reporteeOrigin }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
+						<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.reporterOrigin }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
+					</div>
+					<!-- TODO
 			<div class="inputs" style="display: flex; padding-top: 1.2em;">
 				<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false">
 					<span>{{ $ts.username }}</span>
@@ -33,24 +36,27 @@
 			</div>
 			-->
 
-			<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
-				<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
-			</MkPagination>
+					<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
+						<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
+					</MkPagination>
+				</div>
+			</div>
 		</div>
-	</div>
-</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
 
+import XHeader from './_header_.vue';
 import MkInput from '@/components/form/input.vue';
 import MkSelect from '@/components/form/select.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import XAbuseReport from '@/components/abuse-report.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let reports = $ref<InstanceType<typeof MkPagination>>();
 
@@ -74,12 +80,14 @@ function resolved(reportId) {
 	reports.removeItem(item => item.id === reportId);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.abuseReports,
-		icon: 'fas fa-exclamation-circle',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.abuseReports,
+	icon: 'fas fa-exclamation-circle',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
index b18e08db96..05557469e7 100644
--- a/packages/client/src/pages/admin/ads.vue
+++ b/packages/client/src/pages/admin/ads.vue
@@ -1,21 +1,23 @@
 <template>
-<MkSpacer :content-max="900">
-	<div class="uqshojas">
-		<div v-for="ad in ads" class="_panel _formRoot ad">
-			<MkAd v-if="ad.url" :specify="ad"/>
-			<MkInput v-model="ad.url" type="url" class="_formBlock">
-				<template #label>URL</template>
-			</MkInput>
-			<MkInput v-model="ad.imageUrl" class="_formBlock">
-				<template #label>{{ i18n.ts.imageUrl }}</template>
-			</MkInput>
-			<FormRadios v-model="ad.place" class="_formBlock">
-				<template #label>Form</template>
-				<option value="square">square</option>
-				<option value="horizontal">horizontal</option>
-				<option value="horizontal-big">horizontal-big</option>
-			</FormRadios>
-			<!--
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="uqshojas">
+			<div v-for="ad in ads" class="_panel _formRoot ad">
+				<MkAd v-if="ad.url" :specify="ad"/>
+				<MkInput v-model="ad.url" type="url" class="_formBlock">
+					<template #label>URL</template>
+				</MkInput>
+				<MkInput v-model="ad.imageUrl" class="_formBlock">
+					<template #label>{{ i18n.ts.imageUrl }}</template>
+				</MkInput>
+				<FormRadios v-model="ad.place" class="_formBlock">
+					<template #label>Form</template>
+					<option value="square">square</option>
+					<option value="horizontal">horizontal</option>
+					<option value="horizontal-big">horizontal-big</option>
+				</FormRadios>
+				<!--
 			<div style="margin: 32px 0;">
 				{{ i18n.ts.priority }}
 				<MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio>
@@ -23,36 +25,38 @@
 				<MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
 			</div>
 			-->
-			<FormSplit>
-				<MkInput v-model="ad.ratio" type="number">
-					<template #label>{{ i18n.ts.ratio }}</template>
-				</MkInput>
-				<MkInput v-model="ad.expiresAt" type="date">
-					<template #label>{{ i18n.ts.expiration }}</template>
-				</MkInput>
-			</FormSplit>
-			<MkTextarea v-model="ad.memo" class="_formBlock">
-				<template #label>{{ i18n.ts.memo }}</template>
-			</MkTextarea>
-			<div class="buttons _formBlock">
-				<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
-				<MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+				<FormSplit>
+					<MkInput v-model="ad.ratio" type="number">
+						<template #label>{{ i18n.ts.ratio }}</template>
+					</MkInput>
+					<MkInput v-model="ad.expiresAt" type="date">
+						<template #label>{{ i18n.ts.expiration }}</template>
+					</MkInput>
+				</FormSplit>
+				<MkTextarea v-model="ad.memo" class="_formBlock">
+					<template #label>{{ i18n.ts.memo }}</template>
+				</MkTextarea>
+				<div class="buttons _formBlock">
+					<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+					<MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+				</div>
 			</div>
 		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import FormRadios from '@/components/form/radios.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let ads: any[] = $ref([]);
 
@@ -81,7 +85,7 @@ function remove(ad) {
 		if (canceled) return;
 		ads = ads.filter(x => x !== ad);
 		os.apiWithDialog('admin/ad/delete', {
-			id: ad.id
+			id: ad.id,
 		});
 	});
 }
@@ -90,28 +94,29 @@ function save(ad) {
 	if (ad.id == null) {
 		os.apiWithDialog('admin/ad/create', {
 			...ad,
-			expiresAt: new Date(ad.expiresAt).getTime()
+			expiresAt: new Date(ad.expiresAt).getTime(),
 		});
 	} else {
 		os.apiWithDialog('admin/ad/update', {
 			...ad,
-			expiresAt: new Date(ad.expiresAt).getTime()
+			expiresAt: new Date(ad.expiresAt).getTime(),
 		});
 	}
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.ads,
-		icon: 'fas fa-audio-description',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.add,
-			handler: add,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.add,
+	handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.ads,
+	icon: 'fas fa-audio-description',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
index 97774975de..025897d093 100644
--- a/packages/client/src/pages/admin/announcements.vue
+++ b/packages/client/src/pages/admin/announcements.vue
@@ -1,34 +1,40 @@
 <template>
-<div class="ztgjmzrw">
-	<section v-for="announcement in announcements" class="_card _gap announcements">
-		<div class="_content announcement">
-			<MkInput v-model="announcement.title">
-				<template #label>{{ i18n.ts.title }}</template>
-			</MkInput>
-			<MkTextarea v-model="announcement.text">
-				<template #label>{{ i18n.ts.text }}</template>
-			</MkTextarea>
-			<MkInput v-model="announcement.imageUrl">
-				<template #label>{{ i18n.ts.imageUrl }}</template>
-			</MkInput>
-			<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
-			<div class="buttons">
-				<MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
-				<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
-			</div>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900">
+		<div class="ztgjmzrw">
+			<section v-for="announcement in announcements" class="_card _gap announcements">
+				<div class="_content announcement">
+					<MkInput v-model="announcement.title">
+						<template #label>{{ i18n.ts.title }}</template>
+					</MkInput>
+					<MkTextarea v-model="announcement.text">
+						<template #label>{{ i18n.ts.text }}</template>
+					</MkTextarea>
+					<MkInput v-model="announcement.imageUrl">
+						<template #label>{{ i18n.ts.imageUrl }}</template>
+					</MkInput>
+					<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
+					<div class="buttons">
+						<MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+						<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+					</div>
+				</div>
+			</section>
 		</div>
-	</section>
-</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let announcements: any[] = $ref([]);
 
@@ -41,7 +47,7 @@ function add() {
 		id: null,
 		title: '',
 		text: '',
-		imageUrl: null
+		imageUrl: null,
 	});
 }
 
@@ -61,41 +67,42 @@ function save(announcement) {
 		os.api('admin/announcements/create', announcement).then(() => {
 			os.alert({
 				type: 'success',
-				text: i18n.ts.saved
+				text: i18n.ts.saved,
 			});
 		}).catch(err => {
 			os.alert({
 				type: 'error',
-				text: err
+				text: err,
 			});
 		});
 	} else {
 		os.api('admin/announcements/update', announcement).then(() => {
 			os.alert({
 				type: 'success',
-				text: i18n.ts.saved
+				text: i18n.ts.saved,
 			});
 		}).catch(err => {
 			os.alert({
 				type: 'error',
-				text: err
+				text: err,
 			});
 		});
 	}
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.announcements,
-		icon: 'fas fa-broadcast-tower',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.add,
-			handler: add,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.add,
+	handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.announcements,
+	icon: 'fas fa-broadcast-tower',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
index 30fee5015a..d2e7919b4f 100644
--- a/packages/client/src/pages/admin/bot-protection.vue
+++ b/packages/client/src/pages/admin/bot-protection.vue
@@ -51,7 +51,6 @@ import FormButton from '@/components/ui/button.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import FormSlot from '@/components/form/slot.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 
 const MkCaptcha = defineAsyncComponent(() => import('@/components/captcha.vue'));
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
index d3519922b1..b9c5f9e393 100644
--- a/packages/client/src/pages/admin/database.vue
+++ b/packages/client/src/pages/admin/database.vue
@@ -1,12 +1,13 @@
-<template>
-<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
 	<FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
 		<MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
 			<template #key>{{ table[0] }}</template>
 			<template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template>
 		</MkKeyValue>
 	</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -14,18 +15,20 @@ import { } from 'vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import bytes from '@/filters/bytes';
 import number from '@/filters/number';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.database,
-		icon: 'fas fa-database',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.database,
+	icon: 'fas fa-database',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
index aa13043193..5487c5f333 100644
--- a/packages/client/src/pages/admin/email-settings.vue
+++ b/packages/client/src/pages/admin/email-settings.vue
@@ -1,49 +1,53 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormSwitch v-model="enableEmail" class="_formBlock">
-				<template #label>{{ i18n.ts.enableEmail }}</template>
-				<template #caption>{{ i18n.ts.emailConfigInfo }}</template>
-			</FormSwitch>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormSwitch v-model="enableEmail" class="_formBlock">
+					<template #label>{{ i18n.ts.enableEmail }}</template>
+					<template #caption>{{ i18n.ts.emailConfigInfo }}</template>
+				</FormSwitch>
 
-			<template v-if="enableEmail">
-				<FormInput v-model="email" type="email" class="_formBlock">
-					<template #label>{{ i18n.ts.emailAddress }}</template>
-				</FormInput>
+				<template v-if="enableEmail">
+					<FormInput v-model="email" type="email" class="_formBlock">
+						<template #label>{{ i18n.ts.emailAddress }}</template>
+					</FormInput>
 
-				<FormSection>
-					<template #label>{{ i18n.ts.smtpConfig }}</template>
-					<FormSplit :min-width="280">
-						<FormInput v-model="smtpHost" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpHost }}</template>
-						</FormInput>
-						<FormInput v-model="smtpPort" type="number" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpPort }}</template>
-						</FormInput>
-					</FormSplit>
-					<FormSplit :min-width="280">
-						<FormInput v-model="smtpUser" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpUser }}</template>
-						</FormInput>
-						<FormInput v-model="smtpPass" type="password" class="_formBlock">
-							<template #label>{{ i18n.ts.smtpPass }}</template>
-						</FormInput>
-					</FormSplit>
-					<FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
-					<FormSwitch v-model="smtpSecure" class="_formBlock">
-						<template #label>{{ i18n.ts.smtpSecure }}</template>
-						<template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
-					</FormSwitch>
-				</FormSection>
-			</template>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+					<FormSection>
+						<template #label>{{ i18n.ts.smtpConfig }}</template>
+						<FormSplit :min-width="280">
+							<FormInput v-model="smtpHost" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpHost }}</template>
+							</FormInput>
+							<FormInput v-model="smtpPort" type="number" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpPort }}</template>
+							</FormInput>
+						</FormSplit>
+						<FormSplit :min-width="280">
+							<FormInput v-model="smtpUser" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpUser }}</template>
+							</FormInput>
+							<FormInput v-model="smtpPass" type="password" class="_formBlock">
+								<template #label>{{ i18n.ts.smtpPass }}</template>
+							</FormInput>
+						</FormSplit>
+						<FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
+						<FormSwitch v-model="smtpSecure" class="_formBlock">
+							<template #label>{{ i18n.ts.smtpSecure }}</template>
+							<template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
+						</FormSwitch>
+					</FormSection>
+				</template>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInput from '@/components/form/input.vue';
 import FormInfo from '@/components/ui/info.vue';
@@ -51,9 +55,9 @@ import FormSuspense from '@/components/form/suspense.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormSection from '@/components/form/section.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance, instance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let enableEmail: boolean = $ref(false);
 let email: any = $ref(null);
@@ -78,13 +82,13 @@ async function testEmail() {
 	const { canceled, result: destination } = await os.inputText({
 		title: i18n.ts.destination,
 		type: 'email',
-		placeholder: instance.maintainerEmail
+		placeholder: instance.maintainerEmail,
 	});
 	if (canceled) return;
 	os.apiWithDialog('admin/send-email', {
 		to: destination,
 		subject: 'Test email',
-		text: 'Yo'
+		text: 'Yo',
 	});
 }
 
@@ -102,21 +106,22 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.emailServer,
-		icon: 'fas fa-envelope',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			text: i18n.ts.testEmail,
-			handler: testEmail,
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	text: i18n.ts.testEmail,
+	handler: testEmail,
+}, {
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.emailServer,
+	icon: 'fas fa-envelope',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
index 8ca5b3d65c..9d6b56dbc5 100644
--- a/packages/client/src/pages/admin/emojis.vue
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -1,69 +1,75 @@
 <template>
-<MkSpacer :content-max="900">
-	<div class="ogwlenmc">
-		<div v-if="tab === 'local'" class="local">
-			<MkInput v-model="query" :debounce="true" type="search">
-				<template #prefix><i class="fas fa-search"></i></template>
-				<template #label>{{ $ts.search }}</template>
-			</MkInput>
-			<MkSwitch v-model="selectMode" style="margin: 8px 0;">
-				<template #label>Select mode</template>
-			</MkSwitch>
-			<div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-				<MkButton inline @click="selectAll">Select all</MkButton>
-				<MkButton inline @click="setCategoryBulk">Set category</MkButton>
-				<MkButton inline @click="addTagBulk">Add tag</MkButton>
-				<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
-				<MkButton inline @click="setTagBulk">Set tag</MkButton>
-				<MkButton inline danger @click="delBulk">Delete</MkButton>
-			</div>
-			<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
-				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
-				<template v-slot="{items}">
-					<div class="ldhfsamy">
-						<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
-							<img :src="emoji.url" class="img" :alt="emoji.name"/>
-							<div class="body">
-								<div class="name _monospace">{{ emoji.name }}</div>
-								<div class="info">{{ emoji.category }}</div>
-							</div>
-						</button>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="900">
+			<div class="ogwlenmc">
+				<div v-if="tab === 'local'" class="local">
+					<MkInput v-model="query" :debounce="true" type="search">
+						<template #prefix><i class="fas fa-search"></i></template>
+						<template #label>{{ $ts.search }}</template>
+					</MkInput>
+					<MkSwitch v-model="selectMode" style="margin: 8px 0;">
+						<template #label>Select mode</template>
+					</MkSwitch>
+					<div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+						<MkButton inline @click="selectAll">Select all</MkButton>
+						<MkButton inline @click="setCategoryBulk">Set category</MkButton>
+						<MkButton inline @click="addTagBulk">Add tag</MkButton>
+						<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
+						<MkButton inline @click="setTagBulk">Set tag</MkButton>
+						<MkButton inline danger @click="delBulk">Delete</MkButton>
 					</div>
-				</template>
-			</MkPagination>
-		</div>
-
-		<div v-else-if="tab === 'remote'" class="remote">
-			<FormSplit>
-				<MkInput v-model="queryRemote" :debounce="true" type="search">
-					<template #prefix><i class="fas fa-search"></i></template>
-					<template #label>{{ $ts.search }}</template>
-				</MkInput>
-				<MkInput v-model="host" :debounce="true">
-					<template #label>{{ $ts.host }}</template>
-				</MkInput>
-			</FormSplit>
-			<MkPagination :pagination="remotePagination">
-				<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
-				<template v-slot="{items}">
-					<div class="ldhfsamy">
-						<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
-							<img :src="emoji.url" class="img" :alt="emoji.name"/>
-							<div class="body">
-								<div class="name _monospace">{{ emoji.name }}</div>
-								<div class="info">{{ emoji.host }}</div>
+					<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
+						<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+						<template #default="{items}">
+							<div class="ldhfsamy">
+								<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
+									<img :src="emoji.url" class="img" :alt="emoji.name"/>
+									<div class="body">
+										<div class="name _monospace">{{ emoji.name }}</div>
+										<div class="info">{{ emoji.category }}</div>
+									</div>
+								</button>
 							</div>
-						</div>
-					</div>
-				</template>
-			</MkPagination>
-		</div>
-	</div>
-</MkSpacer>
+						</template>
+					</MkPagination>
+				</div>
+
+				<div v-else-if="tab === 'remote'" class="remote">
+					<FormSplit>
+						<MkInput v-model="queryRemote" :debounce="true" type="search">
+							<template #prefix><i class="fas fa-search"></i></template>
+							<template #label>{{ $ts.search }}</template>
+						</MkInput>
+						<MkInput v-model="host" :debounce="true">
+							<template #label>{{ $ts.host }}</template>
+						</MkInput>
+					</FormSplit>
+					<MkPagination :pagination="remotePagination">
+						<template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+						<template #default="{items}">
+							<div class="ldhfsamy">
+								<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
+									<img :src="emoji.url" class="img" :alt="emoji.name"/>
+									<div class="body">
+										<div class="name _monospace">{{ emoji.name }}</div>
+										<div class="info">{{ emoji.host }}</div>
+									</div>
+								</div>
+							</div>
+						</template>
+					</MkPagination>
+				</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
 </template>
 
 <script lang="ts" setup>
 import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkPagination from '@/components/ui/pagination.vue';
@@ -72,8 +78,8 @@ import MkSwitch from '@/components/form/switch.vue';
 import FormSplit from '@/components/form/split.vue';
 import { selectFile, selectFiles } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>();
 
@@ -131,13 +137,13 @@ const add = async (ev: MouseEvent) => {
 
 const edit = (emoji) => {
 	os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
-		emoji: emoji
+		emoji: emoji,
 	}, {
 		done: result => {
 			if (result.updated) {
 				emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
 					...oldEmoji,
-					...result.updated
+					...result.updated,
 				}));
 			} else if (result.deleted) {
 				emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
@@ -159,7 +165,7 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
 	}, {
 		text: i18n.ts.import,
 		icon: 'fas fa-plus',
-		action: () => { im(emoji); }
+		action: () => { im(emoji); },
 	}], ev.currentTarget ?? ev.target);
 };
 
@@ -181,7 +187,7 @@ const menu = (ev: MouseEvent) => {
 					text: err.message,
 				});
 			});
-		}
+		},
 	}, {
 		icon: 'fas fa-upload',
 		text: i18n.ts.import,
@@ -201,7 +207,7 @@ const menu = (ev: MouseEvent) => {
 					text: err.message,
 				});
 			});
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 };
 
@@ -265,31 +271,31 @@ const delBulk = async () => {
 	emojisPaginationComponent.value.reload();
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.customEmojis,
-		icon: 'fas fa-laugh',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.addEmoji,
-			handler: add,
-		}, {
-			icon: 'fas fa-ellipsis-h',
-			handler: menu,
-		}],
-		tabs: [{
-			active: tab.value === 'local',
-			title: i18n.ts.local,
-			onClick: () => { tab.value = 'local'; },
-		}, {
-			active: tab.value === 'remote',
-			title: i18n.ts.remote,
-			onClick: () => { tab.value = 'remote'; },
-		},]
-	})),
-});
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.addEmoji,
+	handler: add,
+}, {
+	icon: 'fas fa-ellipsis-h',
+	handler: menu,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab.value === 'local',
+	title: i18n.ts.local,
+	onClick: () => { tab.value = 'local'; },
+}, {
+	active: tab.value === 'remote',
+	title: i18n.ts.remote,
+	onClick: () => { tab.value = 'remote'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.customEmojis,
+	icon: 'fas fa-laugh',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
index 9350911b60..18bf4f9a8c 100644
--- a/packages/client/src/pages/admin/files.vue
+++ b/packages/client/src/pages/admin/files.vue
@@ -1,50 +1,58 @@
 <template>
-<div class="xrmjdkdw">
-	<div>
-		<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-			<MkSelect v-model="origin" style="margin: 0; flex: 1;">
-				<template #label>{{ $ts.instance }}</template>
-				<option value="combined">{{ $ts.all }}</option>
-				<option value="local">{{ $ts.local }}</option>
-				<option value="remote">{{ $ts.remote }}</option>
-			</MkSelect>
-			<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
-				<template #label>{{ $ts.host }}</template>
-			</MkInput>
-		</div>
-		<div class="inputs" style="display: flex; padding-top: 1.2em;">
-			<MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
-				<template #label>MIME type</template>
-			</MkInput>
-		</div>
-		<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
-			<button v-for="file in items" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" class="file _panel _button" @click="show(file, $event)">
-				<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
-				<div v-if="viewMode === 'list'" class="body">
-					<div>
-						<small style="opacity: 0.7;">{{ file.name }}</small>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions"/></template>
+		<MkSpacer :content-max="900">
+			<div class="xrmjdkdw">
+				<div>
+					<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+						<MkSelect v-model="origin" style="margin: 0; flex: 1;">
+							<template #label>{{ $ts.instance }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
+						<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
+							<template #label>{{ $ts.host }}</template>
+						</MkInput>
 					</div>
-					<div>
-						<MkAcct v-if="file.user" :user="file.user"/>
-						<div v-else>{{ $ts.system }}</div>
-					</div>
-					<div>
-						<span style="margin-right: 1em;">{{ file.type }}</span>
-						<span>{{ bytes(file.size) }}</span>
-					</div>
-					<div>
-						<span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+					<div class="inputs" style="display: flex; padding-top: 1.2em;">
+						<MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+							<template #label>MIME type</template>
+						</MkInput>
 					</div>
+					<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
+						<button v-for="file in items" :key="file.id" v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" class="file _button" @click="show(file, $event)">
+							<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+							<div v-if="viewMode === 'list'" class="body">
+								<div>
+									<small style="opacity: 0.7;">{{ file.name }}</small>
+								</div>
+								<div>
+									<MkAcct v-if="file.user" :user="file.user"/>
+									<div v-else>{{ $ts.system }}</div>
+								</div>
+								<div>
+									<span style="margin-right: 1em;">{{ file.type }}</span>
+									<span>{{ bytes(file.size) }}</span>
+								</div>
+								<div>
+									<span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+								</div>
+							</div>
+						</button>
+					</MkPagination>
 				</div>
-			</button>
-		</MkPagination>
-	</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
 </div>
 </template>
 
 <script lang="ts" setup>
 import { computed, defineAsyncComponent } from 'vue';
 import * as Acct from 'misskey-js/built/acct';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkSelect from '@/components/form/select.vue';
@@ -53,8 +61,8 @@ import MkContainer from '@/components/ui/container.vue';
 import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
 import bytes from '@/filters/bytes';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let origin = $ref('local');
 let type = $ref(null);
@@ -82,7 +90,7 @@ function clear() {
 }
 
 function show(file) {
-	os.pageWindow(`/admin-file/${file.id}`);
+	os.pageWindow(`/admin/file/${file.id}`);
 }
 
 async function find() {
@@ -104,22 +112,23 @@ async function find() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.files,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-		actions: [{
-			text: i18n.ts.lookup,
-			icon: 'fas fa-search',
-			handler: find,
-		}, {
-			text: i18n.ts.clearCachedFiles,
-			icon: 'fas fa-trash-alt',
-			handler: clear,
-		}],
-	})),
-});
+const headerActions = $computed(() => [{
+	text: i18n.ts.lookup,
+	icon: 'fas fa-search',
+	handler: find,
+}, {
+	text: i18n.ts.clearCachedFiles,
+	icon: 'fas fa-trash-alt',
+	handler: clear,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.files,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
index 9b7fa5678e..5db91101d7 100644
--- a/packages/client/src/pages/admin/index.vue
+++ b/packages/client/src/pages/admin/index.vue
@@ -1,8 +1,6 @@
 <template>
 <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
-	<div v-if="!narrow || initialPage == null" class="nav">
-		<MkHeader :info="header"></MkHeader>
-	
+	<div v-if="!narrow || initialPage == null" class="nav">	
 		<MkSpacer :content-max="700" :margin-min="16">
 			<div class="lxpfedzu">
 				<div class="banner">
@@ -17,29 +15,26 @@
 		</MkSpacer>
 	</div>
 	<div v-if="!(narrow && initialPage == null)" class="main">
-		<MkStickyContainer>
-			<template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
-			<component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/>
-		</MkStickyContainer>
+		<component :is="component" :key="initialPage" v-bind="pageProps"/>
 	</div>
 </div>
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
+import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
 import { i18n } from '@/i18n';
 import MkSuperMenu from '@/components/ui/super-menu.vue';
 import MkInfo from '@/components/ui/info.vue';
 import { scroll } from '@/scripts/scroll';
 import { instance } from '@/instance';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
 import { lookupUser } from '@/scripts/lookup-user';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
 const isEmpty = (x: string | null) => x == null || x === '';
 
-const nav = new MisskeyNavigator();
+const router = useRouter();
 
 const indexInfo = {
 	title: i18n.ts.controlPanel,
@@ -224,7 +219,7 @@ watch(component, () => {
 
 watch(() => props.initialPage, () => {
 	if (props.initialPage == null && !narrow) {
-		nav.push('/admin/overview');
+		router.push('/admin/overview');
 	} else {
 		if (props.initialPage == null) {
 			INFO = indexInfo;
@@ -234,7 +229,7 @@ watch(() => props.initialPage, () => {
 
 watch(narrow, () => {
 	if (props.initialPage == null && !narrow) {
-		nav.push('/admin/overview');
+		router.push('/admin/overview');
 	}
 });
 
@@ -243,7 +238,7 @@ onMounted(() => {
 
 	narrow = el.offsetWidth < NARROW_THRESHOLD;
 	if (props.initialPage == null && !narrow) {
-		nav.push('/admin/overview');
+		router.push('/admin/overview');
 	}
 });
 
@@ -251,19 +246,19 @@ onUnmounted(() => {
 	ro.disconnect();
 });
 
-const pageChanged = (page) => {
-	if (page == null) {
+provideMetadataReceiver((info) => {
+	if (info == null) {
 		childInfo = null;
 	} else {
-		childInfo = page[symbols.PAGE_INFO];
+		childInfo = info;
 	}
-};
+});
 
 const invite = () => {
 	os.api('admin/invite').then(x => {
 		os.alert({
 			type: 'info',
-			text: x.code
+			text: x.code,
 		});
 	}).catch(err => {
 		os.alert({
@@ -279,33 +274,38 @@ const lookup = (ev) => {
 		icon: 'fas fa-user',
 		action: () => {
 			lookupUser();
-		}
+		},
 	}, {
 		text: i18n.ts.note,
 		icon: 'fas fa-pencil-alt',
 		action: () => {
 			alert('TODO');
-		}
+		},
 	}, {
 		text: i18n.ts.file,
 		icon: 'fas fa-cloud',
 		action: () => {
 			alert('TODO');
-		}
+		},
 	}, {
 		text: i18n.ts.instance,
 		icon: 'fas fa-globe',
 		action: () => {
 			alert('TODO');
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 };
 
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+
 defineExpose({
-	[symbols.PAGE_INFO]: INFO,
 	header: {
 		title: i18n.ts.controlPanel,
-	}
+	},
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
index 3347846a80..1aec151abb 100644
--- a/packages/client/src/pages/admin/instance-block.vue
+++ b/packages/client/src/pages/admin/instance-block.vue
@@ -1,25 +1,29 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<FormTextarea v-model="blockedHosts" class="_formBlock">
-			<span>{{ i18n.ts.blockedInstances }}</span>
-			<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
-		</FormTextarea>
-
-		<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
-	</FormSuspense>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<FormTextarea v-model="blockedHosts" class="_formBlock">
+				<span>{{ i18n.ts.blockedInstances }}</span>
+				<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
+			</FormTextarea>
+
+			<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormButton from '@/components/ui/button.vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let blockedHosts: string = $ref('');
 
@@ -36,11 +40,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.instanceBlocking,
-		icon: 'fas fa-ban',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.instanceBlocking,
+	icon: 'fas fa-ban',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
index d6061d0e51..d407d440b9 100644
--- a/packages/client/src/pages/admin/integrations.vue
+++ b/packages/client/src/pages/admin/integrations.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
 	<FormSuspense :p="init">
 		<FormFolder class="_formBlock">
 			<template #icon><i class="fab fa-twitter"></i></template>
@@ -20,19 +21,19 @@
 			<XDiscord/>
 		</FormFolder>
 	</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormSuspense from '@/components/form/suspense.vue';
 import XTwitter from './integrations.twitter.vue';
 import XGithub from './integrations.github.vue';
 import XDiscord from './integrations.discord.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormFolder from '@/components/form/folder.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let enableTwitterIntegration: boolean = $ref(false);
 let enableGithubIntegration: boolean = $ref(false);
@@ -45,11 +46,13 @@ async function init() {
 	enableDiscordIntegration = meta.enableDiscordIntegration;
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.integration,
-		icon: 'fas fa-share-alt',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.integration,
+	icon: 'fas fa-share-alt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
index d109db9c38..bae5277f49 100644
--- a/packages/client/src/pages/admin/object-storage.vue
+++ b/packages/client/src/pages/admin/object-storage.vue
@@ -1,72 +1,76 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
-
-			<template v-if="useObjectStorage">
-				<FormInput v-model="objectStorageBaseUrl" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
-					<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStorageBucket" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageBucket }}</template>
-					<template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStoragePrefix" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStoragePrefix }}</template>
-					<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStorageEndpoint" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
-					<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
-				</FormInput>
-
-				<FormInput v-model="objectStorageRegion" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageRegion }}</template>
-					<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
-				</FormInput>
-
-				<FormSplit :min-width="280">
-					<FormInput v-model="objectStorageAccessKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Access key</template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
+
+				<template v-if="useObjectStorage">
+					<FormInput v-model="objectStorageBaseUrl" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
+						<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
 					</FormInput>
 
-					<FormInput v-model="objectStorageSecretKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Secret key</template>
+					<FormInput v-model="objectStorageBucket" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageBucket }}</template>
+						<template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
 					</FormInput>
-				</FormSplit>
-
-				<FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
-					<template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
-				</FormSwitch>
-
-				<FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
-					<template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
-				</FormSwitch>
-
-				<FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
-					<template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
-				</FormSwitch>
-
-				<FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
-					<template #label>s3ForcePathStyle</template>
-				</FormSwitch>
-			</template>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+
+					<FormInput v-model="objectStoragePrefix" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStoragePrefix }}</template>
+						<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
+					</FormInput>
+
+					<FormInput v-model="objectStorageEndpoint" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
+						<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
+					</FormInput>
+
+					<FormInput v-model="objectStorageRegion" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageRegion }}</template>
+						<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
+					</FormInput>
+
+					<FormSplit :min-width="280">
+						<FormInput v-model="objectStorageAccessKey" class="_formBlock">
+							<template #prefix><i class="fas fa-key"></i></template>
+							<template #label>Access key</template>
+						</FormInput>
+
+						<FormInput v-model="objectStorageSecretKey" class="_formBlock">
+							<template #prefix><i class="fas fa-key"></i></template>
+							<template #label>Secret key</template>
+						</FormInput>
+					</FormSplit>
+
+					<FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
+						<template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
+						<template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
+						<template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
+					</FormSwitch>
+
+					<FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
+						<template #label>s3ForcePathStyle</template>
+					</FormSwitch>
+				</template>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInput from '@/components/form/input.vue';
 import FormGroup from '@/components/form/group.vue';
@@ -74,9 +78,9 @@ import FormSuspense from '@/components/form/suspense.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormSection from '@/components/form/section.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let useObjectStorage: boolean = $ref(false);
 let objectStorageBaseUrl: string | null = $ref(null);
@@ -129,17 +133,18 @@ function save() {
 	});
 }
 
-defineExpose({
-  [symbols.PAGE_INFO]: {
-		title: i18n.ts.objectStorage,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.objectStorage,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
index 552b05f347..59b3503c3c 100644
--- a/packages/client/src/pages/admin/other-settings.vue
+++ b/packages/client/src/pages/admin/other-settings.vue
@@ -1,18 +1,22 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		none
-	</FormSuspense>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			none
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 async function init() {
 	await os.api('admin/meta');
@@ -24,17 +28,18 @@ function save() {
 	});
 }
 
-defineExpose({
-  [symbols.PAGE_INFO]: {
-		title: i18n.ts.other,
-		icon: 'fas fa-cogs',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.other,
+	icon: 'fas fa-cogs',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
index cc69424c3b..82b3c33852 100644
--- a/packages/client/src/pages/admin/overview.vue
+++ b/packages/client/src/pages/admin/overview.vue
@@ -35,7 +35,7 @@
 		</MkContainer>
 	</div>
 
-		<!--<XMetrics/>-->
+	<!--<XMetrics/>-->
 
 	<MkFolder style="margin: var(--margin)">
 		<template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template>
@@ -67,6 +67,7 @@
 
 <script lang="ts" setup>
 import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import XMetrics from './metrics.vue';
 import MkInstanceStats from '@/components/instance-stats.vue';
 import MkNumberDiff from '@/components/number-diff.vue';
 import MkContainer from '@/components/ui/container.vue';
@@ -74,11 +75,10 @@ import MkFolder from '@/components/ui/folder.vue';
 import MkQueueChart from '@/components/queue-chart.vue';
 import { version, url } from '@/config';
 import number from '@/filters/number';
-import XMetrics from './metrics.vue';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let stats: any = $ref(null);
 let serverInfo: any = $ref(null);
@@ -106,7 +106,7 @@ onMounted(async () => {
 	nextTick(() => {
 		queueStatsConnection.send('requestLog', {
 			id: Math.random().toString().substr(2, 8),
-			length: 200
+			length: 200,
 		});
 	});
 });
@@ -115,12 +115,14 @@ onBeforeUnmount(() => {
 	queueStatsConnection.dispose();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.dashboard,
-		icon: 'fas fa-tachometer-alt',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.dashboard,
+	icon: 'fas fa-tachometer-alt',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
index 727e20e7e5..0c5bb1bc9f 100644
--- a/packages/client/src/pages/admin/proxy-account.vue
+++ b/packages/client/src/pages/admin/proxy-account.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
 	<FormSuspense :p="init">
 		<MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
 		<MkKeyValue class="_formBlock">
@@ -9,7 +10,7 @@
 
 		<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
 	</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -19,9 +20,9 @@ import FormButton from '@/components/ui/button.vue';
 import MkInfo from '@/components/ui/info.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let proxyAccount: any = $ref(null);
 let proxyAccountId: any = $ref(null);
@@ -50,11 +51,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.proxyAccount,
-		icon: 'fas fa-ghost',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.proxyAccount,
+	icon: 'fas fa-ghost',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
index 656b18199f..c2865525ab 100644
--- a/packages/client/src/pages/admin/queue.vue
+++ b/packages/client/src/pages/admin/queue.vue
@@ -1,24 +1,28 @@
 <template>
-<MkSpacer :content-max="800">
-	<XQueue :connection="connection" domain="inbox">
-		<template #title>In</template>
-	</XQueue>
-	<XQueue :connection="connection" domain="deliver">
-		<template #title>Out</template>
-	</XQueue>
-	<MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<XQueue :connection="connection" domain="inbox">
+			<template #title>In</template>
+		</XQueue>
+		<XQueue :connection="connection" domain="deliver">
+			<template #title>Out</template>
+		</XQueue>
+		<MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import MkButton from '@/components/ui/button.vue';
 import XQueue from './queue.chart.vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import * as symbols from '@/symbols';
 import * as config from '@/config';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const connection = markRaw(stream.useChannel('queueStats'));
 
@@ -38,7 +42,7 @@ onMounted(() => {
 	nextTick(() => {
 		connection.send('requestLog', {
 			id: Math.random().toString().substr(2, 8),
-			length: 200
+			length: 200,
 		});
 	});
 });
@@ -47,19 +51,20 @@ onBeforeUnmount(() => {
 	connection.dispose();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.jobQueue,
-		icon: 'fas fa-clipboard-list',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-up-right-from-square',
-			text: i18n.ts.dashboard,
-			handler: () => {
-				window.open(config.url + '/queue', '_blank');
-			},
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-up-right-from-square',
+	text: i18n.ts.dashboard,
+	handler: () => {
+		window.open(config.url + '/queue', '_blank');
+	},
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.jobQueue,
+	icon: 'fas fa-clipboard-list',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
index 1a36bb4753..1ca4f2df09 100644
--- a/packages/client/src/pages/admin/relays.vue
+++ b/packages/client/src/pages/admin/relays.vue
@@ -1,24 +1,28 @@
 <template>
-<MkSpacer :content-max="800">
-	<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
-		<div>{{ relay.inbox }}</div>
-		<div class="status">
-			<i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i>
-			<i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
-			<i v-else class="fas fa-clock icon requesting"></i>
-			<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
+			<div>{{ relay.inbox }}</div>
+			<div class="status">
+				<i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i>
+				<i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
+				<i v-else class="fas fa-clock icon requesting"></i>
+				<span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+			</div>
+			<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
 		</div>
-		<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let relays: any[] = $ref([]);
 
@@ -26,30 +30,30 @@ async function addRelay() {
 	const { canceled, result: inbox } = await os.inputText({
 		title: i18n.ts.addRelay,
 		type: 'url',
-		placeholder: i18n.ts.inboxUrl
+		placeholder: i18n.ts.inboxUrl,
 	});
 	if (canceled) return;
 	os.api('admin/relays/add', {
-		inbox
+		inbox,
 	}).then((relay: any) => {
 		refresh();
 	}).catch((err: any) => {
 		os.alert({
 			type: 'error',
-			text: err.message || err
+			text: err.message || err,
 		});
 	});
 }
 
 function remove(inbox: string) {
 	os.api('admin/relays/remove', {
-		inbox
+		inbox,
 	}).then(() => {
 		refresh();
 	}).catch((err: any) => {
 		os.alert({
 			type: 'error',
-			text: err.message || err
+			text: err.message || err,
 		});
 	});
 }
@@ -62,18 +66,19 @@ function refresh() {
 
 refresh();
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.relays,
-		icon: 'fas fa-globe',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.addRelay,
-			handler: addRelay,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.addRelay,
+	handler: addRelay,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.relays,
+	icon: 'fas fa-globe',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
index 6b8f70cca5..65b08565cd 100644
--- a/packages/client/src/pages/admin/security.vue
+++ b/packages/client/src/pages/admin/security.vue
@@ -1,36 +1,41 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormFolder class="_formBlock">
-				<template #icon><i class="fas fa-shield-alt"></i></template>
-				<template #label>{{ i18n.ts.botProtection }}</template>
-				<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
-				<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
-				<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<FormFolder class="_formBlock">
+					<template #icon><i class="fas fa-shield-alt"></i></template>
+					<template #label>{{ i18n.ts.botProtection }}</template>
+					<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
+					<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
+					<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
 
-				<XBotProtection/>
-			</FormFolder>
+					<XBotProtection/>
+				</FormFolder>
 
-			<FormFolder class="_formBlock">
-				<template #label>Summaly Proxy</template>
+				<FormFolder class="_formBlock">
+					<template #label>Summaly Proxy</template>
 
-				<div class="_formRoot">
-					<FormInput v-model="summalyProxy" class="_formBlock">
-						<template #prefix><i class="fas fa-link"></i></template>
-						<template #label>Summaly Proxy URL</template>
-					</FormInput>
+					<div class="_formRoot">
+						<FormInput v-model="summalyProxy" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>Summaly Proxy URL</template>
+						</FormInput>
 
-					<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
-				</div>
-			</FormFolder>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+						<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+					</div>
+				</FormFolder>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XBotProtection from './bot-protection.vue';
+import XHeader from './_header_.vue';
 import FormFolder from '@/components/form/folder.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInfo from '@/components/ui/info.vue';
@@ -38,11 +43,10 @@ import FormSuspense from '@/components/form/suspense.vue';
 import FormSection from '@/components/form/section.vue';
 import FormInput from '@/components/form/input.vue';
 import FormButton from '@/components/ui/button.vue';
-import XBotProtection from './bot-protection.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let summalyProxy: string = $ref('');
 let enableHcaptcha: boolean = $ref(false);
@@ -63,11 +67,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.security,
-		icon: 'fas fa-lock',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.security,
+	icon: 'fas fa-lock',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
index 6dc30fe50b..a5767cc2c2 100644
--- a/packages/client/src/pages/admin/settings.vue
+++ b/packages/client/src/pages/admin/settings.vue
@@ -1,149 +1,155 @@
 <template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<FormInput v-model="name" class="_formBlock">
-				<template #label>{{ i18n.ts.instanceName }}</template>
-			</FormInput>
-
-			<FormTextarea v-model="description" class="_formBlock">
-				<template #label>{{ i18n.ts.instanceDescription }}</template>
-			</FormTextarea>
-
-			<FormInput v-model="tosUrl" class="_formBlock">
-				<template #prefix><i class="fas fa-link"></i></template>
-				<template #label>{{ i18n.ts.tosUrl }}</template>
-			</FormInput>
-
-			<FormSplit :min-width="300">
-				<FormInput v-model="maintainerName" class="_formBlock">
-					<template #label>{{ i18n.ts.maintainerName }}</template>
-				</FormInput>
-
-				<FormInput v-model="maintainerEmail" type="email" class="_formBlock">
-					<template #prefix><i class="fas fa-envelope"></i></template>
-					<template #label>{{ i18n.ts.maintainerEmail }}</template>
-				</FormInput>
-			</FormSplit>
-
-			<FormTextarea v-model="pinnedUsers" class="_formBlock">
-				<template #label>{{ i18n.ts.pinnedUsers }}</template>
-				<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
-			</FormTextarea>
-
-			<FormSection>
-				<FormSwitch v-model="enableRegistration" class="_formBlock">
-					<template #label>{{ i18n.ts.enableRegistration }}</template>
-				</FormSwitch>
-
-				<FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
-					<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
-				</FormSwitch>
-			</FormSection>
-
-			<FormSection>
-				<FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
-				<FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
-				<FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
-			</FormSection>
-
-			<FormSection>
-				<template #label>{{ i18n.ts.theme }}</template>
-
-				<FormInput v-model="iconUrl" class="_formBlock">
-					<template #prefix><i class="fas fa-link"></i></template>
-					<template #label>{{ i18n.ts.iconUrl }}</template>
-				</FormInput>
-
-				<FormInput v-model="bannerUrl" class="_formBlock">
-					<template #prefix><i class="fas fa-link"></i></template>
-					<template #label>{{ i18n.ts.bannerUrl }}</template>
-				</FormInput>
-
-				<FormInput v-model="backgroundImageUrl" class="_formBlock">
-					<template #prefix><i class="fas fa-link"></i></template>
-					<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
-				</FormInput>
-
-				<FormInput v-model="themeColor" class="_formBlock">
-					<template #prefix><i class="fas fa-palette"></i></template>
-					<template #label>{{ i18n.ts.themeColor }}</template>
-					<template #caption>#RRGGBB</template>
-				</FormInput>
-
-				<FormTextarea v-model="defaultLightTheme" class="_formBlock">
-					<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
-					<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
-				</FormTextarea>
-
-				<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
-					<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
-					<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
-				</FormTextarea>
-			</FormSection>
-
-			<FormSection>
-				<template #label>{{ i18n.ts.files }}</template>
-
-				<FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
-					<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
-					<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
-				</FormSwitch>
-
-				<FormSplit :min-width="280">
-					<FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
-						<template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
-						<template #suffix>MB</template>
-						<template #caption>{{ i18n.ts.inMb }}</template>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+			<FormSuspense :p="init">
+				<div class="_formRoot">
+					<FormInput v-model="name" class="_formBlock">
+						<template #label>{{ i18n.ts.instanceName }}</template>
 					</FormInput>
 
-					<FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
-						<template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
-						<template #suffix>MB</template>
-						<template #caption>{{ i18n.ts.inMb }}</template>
+					<FormTextarea v-model="description" class="_formBlock">
+						<template #label>{{ i18n.ts.instanceDescription }}</template>
+					</FormTextarea>
+
+					<FormInput v-model="tosUrl" class="_formBlock">
+						<template #prefix><i class="fas fa-link"></i></template>
+						<template #label>{{ i18n.ts.tosUrl }}</template>
 					</FormInput>
-				</FormSplit>
-			</FormSection>
 
-			<FormSection>
-				<template #label>ServiceWorker</template>
+					<FormSplit :min-width="300">
+						<FormInput v-model="maintainerName" class="_formBlock">
+							<template #label>{{ i18n.ts.maintainerName }}</template>
+						</FormInput>
 
-				<FormSwitch v-model="enableServiceWorker" class="_formBlock">
-					<template #label>{{ i18n.ts.enableServiceworker }}</template>
-					<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
-				</FormSwitch>
+						<FormInput v-model="maintainerEmail" type="email" class="_formBlock">
+							<template #prefix><i class="fas fa-envelope"></i></template>
+							<template #label>{{ i18n.ts.maintainerEmail }}</template>
+						</FormInput>
+					</FormSplit>
 
-				<template v-if="enableServiceWorker">
-					<FormInput v-model="swPublicKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Public key</template>
-					</FormInput>
+					<FormTextarea v-model="pinnedUsers" class="_formBlock">
+						<template #label>{{ i18n.ts.pinnedUsers }}</template>
+						<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
+					</FormTextarea>
 
-					<FormInput v-model="swPrivateKey" class="_formBlock">
-						<template #prefix><i class="fas fa-key"></i></template>
-						<template #label>Private key</template>
-					</FormInput>
-				</template>
-			</FormSection>
-
-			<FormSection>
-				<template #label>DeepL Translation</template>
-
-				<FormInput v-model="deeplAuthKey" class="_formBlock">
-					<template #prefix><i class="fas fa-key"></i></template>
-					<template #label>DeepL Auth Key</template>
-				</FormInput>
-				<FormSwitch v-model="deeplIsPro" class="_formBlock">
-					<template #label>Pro account</template>
-				</FormSwitch>
-			</FormSection>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+					<FormSection>
+						<FormSwitch v-model="enableRegistration" class="_formBlock">
+							<template #label>{{ i18n.ts.enableRegistration }}</template>
+						</FormSwitch>
+
+						<FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
+							<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
+						</FormSwitch>
+					</FormSection>
+
+					<FormSection>
+						<FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
+						<FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
+						<FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
+					</FormSection>
+
+					<FormSection>
+						<template #label>{{ i18n.ts.theme }}</template>
+
+						<FormInput v-model="iconUrl" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>{{ i18n.ts.iconUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="bannerUrl" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>{{ i18n.ts.bannerUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="backgroundImageUrl" class="_formBlock">
+							<template #prefix><i class="fas fa-link"></i></template>
+							<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
+						</FormInput>
+
+						<FormInput v-model="themeColor" class="_formBlock">
+							<template #prefix><i class="fas fa-palette"></i></template>
+							<template #label>{{ i18n.ts.themeColor }}</template>
+							<template #caption>#RRGGBB</template>
+						</FormInput>
+
+						<FormTextarea v-model="defaultLightTheme" class="_formBlock">
+							<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
+							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+						</FormTextarea>
+
+						<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
+							<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
+							<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+						</FormTextarea>
+					</FormSection>
+
+					<FormSection>
+						<template #label>{{ i18n.ts.files }}</template>
+
+						<FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
+							<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
+							<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
+						</FormSwitch>
+
+						<FormSplit :min-width="280">
+							<FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
+								<template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
+								<template #suffix>MB</template>
+								<template #caption>{{ i18n.ts.inMb }}</template>
+							</FormInput>
+
+							<FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
+								<template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
+								<template #suffix>MB</template>
+								<template #caption>{{ i18n.ts.inMb }}</template>
+							</FormInput>
+						</FormSplit>
+					</FormSection>
+
+					<FormSection>
+						<template #label>ServiceWorker</template>
+
+						<FormSwitch v-model="enableServiceWorker" class="_formBlock">
+							<template #label>{{ i18n.ts.enableServiceworker }}</template>
+							<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
+						</FormSwitch>
+
+						<template v-if="enableServiceWorker">
+							<FormInput v-model="swPublicKey" class="_formBlock">
+								<template #prefix><i class="fas fa-key"></i></template>
+								<template #label>Public key</template>
+							</FormInput>
+
+							<FormInput v-model="swPrivateKey" class="_formBlock">
+								<template #prefix><i class="fas fa-key"></i></template>
+								<template #label>Private key</template>
+							</FormInput>
+						</template>
+					</FormSection>
+
+					<FormSection>
+						<template #label>DeepL Translation</template>
+
+						<FormInput v-model="deeplAuthKey" class="_formBlock">
+							<template #prefix><i class="fas fa-key"></i></template>
+							<template #label>DeepL Auth Key</template>
+						</FormInput>
+						<FormSwitch v-model="deeplIsPro" class="_formBlock">
+							<template #label>Pro account</template>
+						</FormSwitch>
+					</FormSection>
+				</div>
+			</FormSuspense>
+		</MkSpacer>
+	</MkStickyContainer>
+</div>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
+import XHeader from './_header_.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormInput from '@/components/form/input.vue';
 import FormTextarea from '@/components/form/textarea.vue';
@@ -152,9 +158,9 @@ import FormSection from '@/components/form/section.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { fetchInstance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let name: string | null = $ref(null);
 let description: string | null = $ref(null);
@@ -240,17 +246,18 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.general,
-		icon: 'fas fa-cog',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.save,
-			handler: save,
-		}],
-	}
+const headerActions = $computed(() => [{
+	asFullButton: true,
+	icon: 'fas fa-check',
+	text: i18n.ts.save,
+	handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.general,
+	icon: 'fas fa-cog',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
index f05aa5ff45..dccf952ba9 100644
--- a/packages/client/src/pages/admin/users.vue
+++ b/packages/client/src/pages/admin/users.vue
@@ -1,76 +1,84 @@
 <template>
-<div class="lknzcolw">
-	<div class="users">
-		<div class="inputs">
-			<MkSelect v-model="sort" style="flex: 1;">
-				<template #label>{{ $ts.sort }}</template>
-				<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
-				<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
-				<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
-				<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
-			</MkSelect>
-			<MkSelect v-model="state" style="flex: 1;">
-				<template #label>{{ $ts.state }}</template>
-				<option value="all">{{ $ts.all }}</option>
-				<option value="available">{{ $ts.normal }}</option>
-				<option value="admin">{{ $ts.administrator }}</option>
-				<option value="moderator">{{ $ts.moderator }}</option>
-				<option value="silenced">{{ $ts.silence }}</option>
-				<option value="suspended">{{ $ts.suspend }}</option>
-			</MkSelect>
-			<MkSelect v-model="origin" style="flex: 1;">
-				<template #label>{{ $ts.instance }}</template>
-				<option value="combined">{{ $ts.all }}</option>
-				<option value="local">{{ $ts.local }}</option>
-				<option value="remote">{{ $ts.remote }}</option>
-			</MkSelect>
-		</div>
-		<div class="inputs">
-			<MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
-				<template #prefix>@</template>
-				<template #label>{{ $ts.username }}</template>
-			</MkInput>
-			<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
-				<template #prefix>@</template>
-				<template #label>{{ $ts.host }}</template>
-			</MkInput>
-		</div>
-
-		<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
-			<button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
-				<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
-				<div class="body">
-					<header>
-						<MkUserName class="name" :user="user"/>
-						<span class="acct">@{{ acct(user) }}</span>
-						<span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span>
-						<span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span>
-						<span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
-						<span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
-					</header>
-					<div>
-						<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
+<div>
+	<MkStickyContainer>
+		<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="900">
+			<div class="lknzcolw">
+				<div class="users">
+					<div class="inputs">
+						<MkSelect v-model="sort" style="flex: 1;">
+							<template #label>{{ $ts.sort }}</template>
+							<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+							<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+							<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+							<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+						</MkSelect>
+						<MkSelect v-model="state" style="flex: 1;">
+							<template #label>{{ $ts.state }}</template>
+							<option value="all">{{ $ts.all }}</option>
+							<option value="available">{{ $ts.normal }}</option>
+							<option value="admin">{{ $ts.administrator }}</option>
+							<option value="moderator">{{ $ts.moderator }}</option>
+							<option value="silenced">{{ $ts.silence }}</option>
+							<option value="suspended">{{ $ts.suspend }}</option>
+						</MkSelect>
+						<MkSelect v-model="origin" style="flex: 1;">
+							<template #label>{{ $ts.instance }}</template>
+							<option value="combined">{{ $ts.all }}</option>
+							<option value="local">{{ $ts.local }}</option>
+							<option value="remote">{{ $ts.remote }}</option>
+						</MkSelect>
 					</div>
-					<div>
-						<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+					<div class="inputs">
+						<MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
+							<template #prefix>@</template>
+							<template #label>{{ $ts.username }}</template>
+						</MkInput>
+						<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
+							<template #prefix>@</template>
+							<template #label>{{ $ts.host }}</template>
+						</MkInput>
 					</div>
+
+					<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
+						<button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
+							<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+							<div class="body">
+								<header>
+									<MkUserName class="name" :user="user"/>
+									<span class="acct">@{{ acct(user) }}</span>
+									<span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span>
+									<span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span>
+									<span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
+									<span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
+								</header>
+								<div>
+									<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
+								</div>
+								<div>
+									<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+								</div>
+							</div>
+						</button>
+					</MkPagination>
 				</div>
-			</button>
-		</MkPagination>
-	</div>
+			</div>
+		</MkSpacer>
+	</MkStickyContainer>
 </div>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
+import XHeader from './_header_.vue';
 import MkInput from '@/components/form/input.vue';
 import MkSelect from '@/components/form/select.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import { acct } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { lookupUser } from '@/scripts/lookup-user';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
 
@@ -89,7 +97,7 @@ const pagination = {
 		username: searchUsername,
 		hostname: searchHost,
 	})),
-	offsetMode: true
+	offsetMode: true,
 };
 
 function searchUser() {
@@ -106,7 +114,7 @@ async function addUser() {
 
 	const { canceled: canceled2, result: password } = await os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled2) return;
 
@@ -122,34 +130,34 @@ function show(user) {
 	os.pageWindow(`/user-info/${user.id}`);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.users,
-		icon: 'fas fa-users',
-		bg: 'var(--bg)',
-		actions: [{
-			icon: 'fas fa-search',
-			text: i18n.ts.search,
-			handler: searchUser
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-plus',
-			text: i18n.ts.addUser,
-			handler: addUser
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-search',
-			text: i18n.ts.lookup,
-			handler: lookupUser
-		}],
-	})),
-});
+const headerActions = $computed(() => [{
+	icon: 'fas fa-search',
+	text: i18n.ts.search,
+	handler: searchUser,
+}, {
+	asFullButton: true,
+	icon: 'fas fa-plus',
+	text: i18n.ts.addUser,
+	handler: addUser,
+}, {
+	asFullButton: true,
+	icon: 'fas fa-search',
+	text: i18n.ts.lookup,
+	handler: lookupUser,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.users,
+	icon: 'fas fa-users',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
 .lknzcolw {
 	> .users {
-		margin: var(--margin);
 
 		> .inputs {
 			display: flex;
diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue
index 53727823a4..9afaa7fd1f 100644
--- a/packages/client/src/pages/announcements.vue
+++ b/packages/client/src/pages/announcements.vue
@@ -1,57 +1,53 @@
 <template>
-<MkSpacer :content-max="800">
-	<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
-		<section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
-			<div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
-			<div class="_content">
-				<Mfm :text="announcement.text"/>
-				<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
-			</div>
-			<div v-if="$i && !announcement.isRead" class="_footer">
-				<MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
-			</div>
-		</section>
-	</MkPagination>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content">
+			<section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement">
+				<div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
+				<div class="_content">
+					<Mfm :text="announcement.text"/>
+					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+				</div>
+				<div v-if="$i && !announcement.isRead" class="_footer">
+					<MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
+				</div>
+			</section>
+		</MkPagination>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkPagination,
-		MkButton
-	},
+const pagination = {
+	endpoint: 'announcements' as const,
+	limit: 10,
+};
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.announcements,
-				icon: 'fas fa-broadcast-tower',
-				bg: 'var(--bg)',
-			},
-			pagination: {
-				endpoint: 'announcements' as const,
-				limit: 10,
-			},
-		};
-	},
+// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
+function read(items, announcement, i) {
+	items[i] = {
+		...announcement,
+		isRead: true,
+	};
+	os.api('i/read-announcement', { announcementId: announcement.id });
+}
 
-	methods: {
-		// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
-		read(items, announcement, i) {
-			items[i] = {
-				...announcement,
-				isRead: true,
-			};
-			os.api('i/read-announcement', { announcementId: announcement.id });
-		},
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.announcements,
+	icon: 'fas fa-broadcast-tower',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue
index c38f285725..29b6066fc9 100644
--- a/packages/client/src/pages/antenna-timeline.vue
+++ b/packages/client/src/pages/antenna-timeline.vue
@@ -1,8 +1,9 @@
 <template>
-<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
+<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
 	<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
 	<div class="tl _block">
-		<XTimeline ref="tl" :key="antennaId"
+		<XTimeline
+			ref="tlEl" :key="antennaId"
 			class="tl"
 			src="antenna"
 			:antenna="antennaId"
@@ -13,92 +14,78 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import XTimeline from '@/components/timeline.vue';
 import { scroll } from '@/scripts/scroll';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		XTimeline,
-	},
-
-	props: {
-		antennaId: {
-			type: String,
-			required: true
-		}
-	},
-
-	data() {
-		return {
-			antenna: null,
-			queue: 0,
-			[symbols.PAGE_INFO]: computed(() => this.antenna ? {
-				title: this.antenna.name,
-				icon: 'fas fa-satellite',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-calendar-alt',
-					text: this.$ts.jumpToSpecifiedDate,
-					handler: this.timetravel
-				}, {
-					icon: 'fas fa-cog',
-					text: this.$ts.settings,
-					handler: this.settings
-				}],
-			} : null),
-		};
-	},
-
-	computed: {
-		keymap(): any {
-			return {
-				't': this.focus
-			};
-		},
-	},
-
-	watch: {
-		antennaId: {
-			async handler() {
-				this.antenna = await os.api('antennas/show', {
-					antennaId: this.antennaId
-				});
-			},
-			immediate: true
-		}
-	},
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import i18n from '@/components/global/i18n';
+
+const router = useRouter();
+
+const props = defineProps<{
+	antennaId: string;
+}>();
+
+let antenna = $ref(null);
+let queue = $ref(0);
+let rootEl = $ref<HTMLElement>();
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+const keymap = $computed(() => ({
+	't': focus,
+}));
+
+function queueUpdated(q) {
+	queue = q;
+}
 
-	methods: {
-		queueUpdated(q) {
-			this.queue = q;
-		},
+function top() {
+	scroll(rootEl, { top: 0 });
+}
 
-		top() {
-			scroll(this.$el, { top: 0 });
-		},
+async function timetravel() {
+	const { canceled, result: date } = await os.inputDate({
+		title: i18n.ts.date,
+	});
+	if (canceled) return;
 
-		async timetravel() {
-			const { canceled, result: date } = await os.inputDate({
-				title: this.$ts.date,
-			});
-			if (canceled) return;
+	tlEl.timetravel(date);
+}
 
-			this.$refs.tl.timetravel(date);
-		},
+function settings() {
+	router.push(`/my/antennas/${props.antennaId}`);
+}
 
-		settings() {
-			this.$router.push(`/my/antennas/${this.antennaId}`);
-		},
+function focus() {
+	tlEl.focus();
+}
 
-		focus() {
-			(this.$refs.tl as any).focus();
-		}
-	}
-});
+watch(() => props.antennaId, async () => {
+	antenna = await os.api('antennas/show', {
+		antennaId: props.antennaId,
+	});
+}, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => antenna ? {
+	title: antenna.name,
+	icon: 'fas fa-satellite',
+	bg: 'var(--bg)',
+	actions: [{
+		icon: 'fas fa-calendar-alt',
+		text: i18n.ts.jumpToSpecifiedDate,
+		handler: timetravel,
+	}, {
+		icon: 'fas fa-cog',
+		text: i18n.ts.settings,
+		handler: settings,
+	}],
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue
index 88acbcd3a3..2f8eeadff1 100644
--- a/packages/client/src/pages/api-console.vue
+++ b/packages/client/src/pages/api-console.vue
@@ -1,40 +1,43 @@
 <template>
-<MkSpacer :content-max="700">
-	<div class="_formRoot">
-		<div class="_formBlock">
-			<MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()">
-				<template #label>Endpoint</template>
-			</MkInput>
-			<MkTextarea v-model="body" class="_formBlock" code>
-				<template #label>Params (JSON or JSON5)</template>
-			</MkTextarea>
-			<MkSwitch v-model="withCredential" class="_formBlock">
-				With credential
-			</MkSwitch>
-			<MkButton class="_formBlock" primary :disabled="sending" @click="send">
-				<template v-if="sending"><MkEllipsis/></template>
-				<template v-else><i class="fas fa-paper-plane"></i> Send</template>
-			</MkButton>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="_formRoot">
+			<div class="_formBlock">
+				<MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()">
+					<template #label>Endpoint</template>
+				</MkInput>
+				<MkTextarea v-model="body" class="_formBlock" code>
+					<template #label>Params (JSON or JSON5)</template>
+				</MkTextarea>
+				<MkSwitch v-model="withCredential" class="_formBlock">
+					With credential
+				</MkSwitch>
+				<MkButton class="_formBlock" primary :disabled="sending" @click="send">
+					<template v-if="sending"><MkEllipsis/></template>
+					<template v-else><i class="fas fa-paper-plane"></i> Send</template>
+				</MkButton>
+			</div>
+			<div v-if="res" class="_formBlock">
+				<MkTextarea v-model="res" code readonly tall>
+					<template #label>Response</template>
+				</MkTextarea>
+			</div>
 		</div>
-		<div v-if="res" class="_formBlock">
-			<MkTextarea v-model="res" code readonly tall>
-				<template #label>Response</template>
-			</MkTextarea>
-		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { ref } from 'vue';
 import JSON5 from 'json5';
+import { Endpoints } from 'misskey-js';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import MkSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { Endpoints } from 'misskey-js';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const body = ref('{}');
 const endpoint = ref('');
@@ -75,10 +78,12 @@ function onEndpointChange() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'API console',
-		icon: 'fas fa-terminal'
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'API console',
+	icon: 'fas fa-terminal',
 });
 </script>
diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue
index e65161dd2b..9457cd6b2f 100644
--- a/packages/client/src/pages/auth.vue
+++ b/packages/client/src/pages/auth.vue
@@ -15,7 +15,7 @@
 		<h1>{{ $ts._auth.denied }}</h1>
 	</div>
 	<div v-if="state == 'accepted'" class="accepted">
-		<h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$ts.allowed }}</h1>
+		<h1>{{ session.app.isAuthorized ? $t('already-authorized') : $ts.allowed }}</h1>
 		<p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p>
 		<p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p>
 	</div>
@@ -40,24 +40,20 @@ export default defineComponent({
 		XForm,
 		MkSignin,
 	},
+	props: ['token'],
 	data() {
 		return {
 			state: null,
 			session: null,
-			fetching: true
+			fetching: true,
 		};
 	},
-	computed: {
-		token(): string {
-			return this.$route.params.token;
-		}
-	},
 	mounted() {
 		if (!this.$i) return;
 
 		// Fetch session
 		os.api('auth/session/show', {
-			token: this.token
+			token: this.token,
 		}).then(session => {
 			this.session = session;
 			this.fetching = false;
@@ -65,7 +61,7 @@ export default defineComponent({
 			// 既に連携していた場合
 			if (this.session.app.isAuthorized) {
 				os.api('auth/accept', {
-					token: this.session.token
+					token: this.session.token,
 				}).then(() => {
 					this.accepted();
 				});
@@ -85,8 +81,8 @@ export default defineComponent({
 			}
 		}, onLogin(res) {
 			login(res.i);
-		}
-	}
+		},
+	},
 });
 </script>
 
diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue
index ea3a5dab76..2065bd6689 100644
--- a/packages/client/src/pages/channel-editor.vue
+++ b/packages/client/src/pages/channel-editor.vue
@@ -1,127 +1,122 @@
 <template>
-<MkSpacer :content-max="700">
-	<div class="_formRoot">
-		<MkInput v-model="name" class="_formBlock">
-			<template #label>{{ $ts.name }}</template>
-		</MkInput>
-
-		<MkTextarea v-model="description" class="_formBlock">
-			<template #label>{{ $ts.description }}</template>
-		</MkTextarea>
-
-		<div class="banner">
-			<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
-			<div v-else-if="bannerUrl">
-				<img :src="bannerUrl" style="width: 100%;"/>
-				<MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div class="_formRoot">
+			<MkInput v-model="name" class="_formBlock">
+				<template #label>{{ $ts.name }}</template>
+			</MkInput>
+
+			<MkTextarea v-model="description" class="_formBlock">
+				<template #label>{{ $ts.description }}</template>
+			</MkTextarea>
+
+			<div class="banner">
+				<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
+				<div v-else-if="bannerUrl">
+					<img :src="bannerUrl" style="width: 100%;"/>
+					<MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+				</div>
+			</div>
+			<div class="_formBlock">
+				<MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
 			</div>
 		</div>
-		<div class="_formBlock">
-			<MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
-		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import MkTextarea from '@/components/form/textarea.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
 import { selectFile } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkTextarea, MkButton, MkInput,
-	},
-
-	props: {
-		channelId: {
-			type: String,
-			required: false
-		},
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.channelId ? {
-				title: this.$ts._channel.edit,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-			} : {
-				title: this.$ts._channel.create,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-			}),
-			channel: null,
-			name: null,
-			description: null,
-			bannerUrl: null,
-			bannerId: null,
-		};
-	},
-
-	watch: {
-		async bannerId() {
-			if (this.bannerId == null) {
-				this.bannerUrl = null;
-			} else {
-				this.bannerUrl = (await os.api('drive/files/show', {
-					fileId: this.bannerId,
-				})).url;
-			}
-		},
-	},
-
-	async created() {
-		if (this.channelId) {
-			this.channel = await os.api('channels/show', {
-				channelId: this.channelId,
-			});
-
-			this.name = this.channel.name;
-			this.description = this.channel.description;
-			this.bannerId = this.channel.bannerId;
-			this.bannerUrl = this.channel.bannerUrl;
-		}
-	},
-
-	methods: {
-		save() {
-			const params = {
-				name: this.name,
-				description: this.description,
-				bannerId: this.bannerId,
-			};
-
-			if (this.channelId) {
-				params.channelId = this.channelId;
-				os.api('channels/update', params)
-				.then(channel => {
-					os.success();
-				});
-			} else {
-				os.api('channels/create', params)
-				.then(channel => {
-					os.success();
-					this.$router.push(`/channels/${channel.id}`);
-				});
-			}
-		},
-
-		setBannerImage(evt) {
-			selectFile(evt.currentTarget ?? evt.target, null).then(file => {
-				this.bannerId = file.id;
-			});
-		},
-
-		removeBannerImage() {
-			this.bannerId = null;
-		}
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const router = useRouter();
+
+const props = defineProps<{
+	channelId?: string;
+}>();
+
+let channel = $ref(null);
+let name = $ref(null);
+let description = $ref(null);
+let bannerUrl = $ref<string | null>(null);
+let bannerId = $ref<string | null>(null);
+
+watch(() => bannerId, async () => {
+	if (bannerId == null) {
+		bannerUrl = null;
+	} else {
+		bannerUrl = (await os.api('drive/files/show', {
+			fileId: bannerId,
+		})).url;
 	}
 });
+
+async function fetchChannel() {
+	if (props.channelId == null) return;
+
+	channel = await os.api('channels/show', {
+		channelId: props.channelId,
+	});
+
+	name = channel.name;
+	description = channel.description;
+	bannerId = channel.bannerId;
+	bannerUrl = channel.bannerUrl;
+}
+
+fetchChannel();
+
+function save() {
+	const params = {
+		name: name,
+		description: description,
+		bannerId: bannerId,
+	};
+
+	if (props.channelId) {
+		params.channelId = props.channelId;
+		os.api('channels/update', params).then(() => {
+			os.success();
+		});
+	} else {
+		os.api('channels/create', params).then(created => {
+			os.success();
+			router.push(`/channels/${created.id}`);
+		});
+	}
+}
+
+function setBannerImage(evt) {
+	selectFile(evt.currentTarget ?? evt.target, null).then(file => {
+		bannerId = file.id;
+	});
+}
+
+function removeBannerImage() {
+	bannerId = null;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.channelId ? {
+	title: i18n.ts._channel.edit,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+} : {
+	title: i18n.ts._channel.create,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+}));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue
index c9a8f36844..003ad5cce9 100644
--- a/packages/client/src/pages/channel.vue
+++ b/packages/client/src/pages/channel.vue
@@ -1,98 +1,87 @@
 <template>
-<MkSpacer :content-max="700">
-	<div v-if="channel">
-		<div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
-			<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
-			<button class="_button toggle" @click="() => showBanner = !showBanner">
-				<template v-if="showBanner"><i class="fas fa-angle-up"></i></template>
-				<template v-else><i class="fas fa-angle-down"></i></template>
-			</button>
-			<div v-if="!showBanner" class="hideOverlay">
-			</div>
-			<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
-				<div class="status">
-					<div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
-					<div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="channel">
+			<div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }">
+				<XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
+				<button class="_button toggle" @click="() => showBanner = !showBanner">
+					<template v-if="showBanner"><i class="fas fa-angle-up"></i></template>
+					<template v-else><i class="fas fa-angle-down"></i></template>
+				</button>
+				<div v-if="!showBanner" class="hideOverlay">
+				</div>
+				<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
+					<div class="status">
+						<div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
+						<div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+					</div>
+					<div class="fade"></div>
+				</div>
+				<div v-if="channel.description" class="description">
+					<Mfm :text="channel.description" :is-note="false" :i="$i"/>
 				</div>
-				<div class="fade"></div>
-			</div>
-			<div v-if="channel.description" class="description">
-				<Mfm :text="channel.description" :is-note="false" :i="$i"/>
 			</div>
-		</div>
 
-		<XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
+			<XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/>
 
-		<XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
-	</div>
-</MkSpacer>
+			<XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import MkContainer from '@/components/ui/container.vue';
 import XPostForm from '@/components/post-form.vue';
 import XTimeline from '@/components/timeline.vue';
 import XChannelFollowButton from '@/components/channel-follow-button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkContainer,
-		XPostForm,
-		XTimeline,
-		XChannelFollowButton
-	},
-
-	props: {
-		channelId: {
-			type: String,
-			required: true
-		}
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.channel ? {
-				title: this.channel.name,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-				actions: [...(this.$i && this.$i.id === this.channel.userId ? [{
-					icon: 'fas fa-cog',
-					text: this.$ts.edit,
-					handler: this.edit,
-				}] : [])],
-			} : null),
-			channel: null,
-			showBanner: true,
-			pagination: {
-				endpoint: 'channels/timeline' as const,
-				limit: 10,
-				params: computed(() => ({
-					channelId: this.channelId,
-				}))
-			},
-		};
-	},
-
-	watch: {
-		channelId: {
-			async handler() {
-				this.channel = await os.api('channels/show', {
-					channelId: this.channelId,
-				});
-			},
-			immediate: true
-		}
-	},
+import { useRouter } from '@/router';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const router = useRouter();
+
+const props = defineProps<{
+	channelId: string;
+}>();
+
+let channel = $ref(null);
+let showBanner = $ref(true);
+const pagination = {
+	endpoint: 'channels/timeline' as const,
+	limit: 10,
+	params: computed(() => ({
+		channelId: props.channelId,
+	})),
+};
+
+watch(() => props.channelId, async () => {
+	channel = await os.api('channels/show', {
+		channelId: props.channelId,
+	});
+}, { immediate: true });
+
+function edit() {
+	router.push(`/channels/${channel.id}/edit`);
+}
 
-	methods: {
-		edit() {
-			this.$router.push(`/channels/${this.channel.id}/edit`);
-		}
-	},
-});
+const headerActions = $computed(() => channel && channel.userId ? [{
+	icon: 'fas fa-cog',
+	text: i18n.ts.edit,
+	handler: edit,
+}] : null);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => channel ? {
+	title: channel.name,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
index 4e538a6da3..89d23350f2 100644
--- a/packages/client/src/pages/channels.vue
+++ b/packages/client/src/pages/channels.vue
@@ -1,82 +1,83 @@
 <template>
-<MkSpacer :content-max="700">
-	<div v-if="tab === 'featured'" class="_content grwlizim featured">
-		<MkPagination v-slot="{items}" :pagination="featuredPagination">
-			<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-		</MkPagination>
-	</div>
-	<div v-else-if="tab === 'following'" class="_content grwlizim following">
-		<MkPagination v-slot="{items}" :pagination="followingPagination">
-			<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-		</MkPagination>
-	</div>
-	<div v-else-if="tab === 'owned'" class="_content grwlizim owned">
-		<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
-		<MkPagination v-slot="{items}" :pagination="ownedPagination">
-			<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
-		</MkPagination>
-	</div>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="tab === 'featured'" class="_content grwlizim featured">
+			<MkPagination v-slot="{items}" :pagination="featuredPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'following'" class="_content grwlizim following">
+			<MkPagination v-slot="{items}" :pagination="followingPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+		<div v-else-if="tab === 'owned'" class="_content grwlizim owned">
+			<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+			<MkPagination v-slot="{items}" :pagination="ownedPagination">
+				<MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, inject } from 'vue';
 import MkChannelPreview from '@/components/channel-preview.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		MkChannelPreview, MkPagination, MkButton,
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.channel,
-				icon: 'fas fa-satellite-dish',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-plus',
-					text: this.$ts.create,
-					handler: this.create,
-				}],
-				tabs: [{
-					active: this.tab === 'featured',
-					title: this.$ts._channel.featured,
-					icon: 'fas fa-fire-alt',
-					onClick: () => { this.tab = 'featured'; },
-				}, {
-					active: this.tab === 'following',
-					title: this.$ts._channel.following,
-					icon: 'fas fa-heart',
-					onClick: () => { this.tab = 'following'; },
-				}, {
-					active: this.tab === 'owned',
-					title: this.$ts._channel.owned,
-					icon: 'fas fa-edit',
-					onClick: () => { this.tab = 'owned'; },
-				},]
-			})),
-			tab: 'featured',
-			featuredPagination: {
-				endpoint: 'channels/featured' as const,
-				noPaging: true,
-			},
-			followingPagination: {
-				endpoint: 'channels/followed' as const,
-				limit: 5,
-			},
-			ownedPagination: {
-				endpoint: 'channels/owned' as const,
-				limit: 5,
-			},
-		};
-	},
-	methods: {
-		create() {
-			this.$router.push(`/channels/new`);
-		}
-	}
-});
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagination = {
+	endpoint: 'channels/featured' as const,
+	noPaging: true,
+};
+const followingPagination = {
+	endpoint: 'channels/followed' as const,
+	limit: 5,
+};
+const ownedPagination = {
+	endpoint: 'channels/owned' as const,
+	limit: 5,
+};
+
+function create() {
+	router.push('/channels/new');
+}
+
+const headerActions = $computed(() => [{
+	icon: 'fas fa-plus',
+	text: i18n.ts.create,
+	handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'featured',
+	title: i18n.ts._channel.featured,
+	icon: 'fas fa-fire-alt',
+	onClick: () => { tab = 'featured'; },
+}, {
+	active: tab === 'following',
+	title: i18n.ts._channel.following,
+	icon: 'fas fa-heart',
+	onClick: () => { tab = 'following'; },
+}, {
+	active: tab === 'owned',
+	title: i18n.ts._channel.owned,
+	icon: 'fas fa-edit',
+	onClick: () => { tab = 'owned'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.channel,
+	icon: 'fas fa-satellite-dish',
+	bg: 'var(--bg)',
+})));
 </script>
diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue
index 163384337e..ce21b4c809 100644
--- a/packages/client/src/pages/clip.vue
+++ b/packages/client/src/pages/clip.vue
@@ -1,18 +1,21 @@
 <template>
-<MkSpacer :content-max="800">
-	<div v-if="clip">
-		<div class="okzinsic _panel">
-			<div v-if="clip.description" class="description">
-				<Mfm :text="clip.description" :is-note="false" :i="$i"/>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions"/></template>
+		<MkSpacer :content-max="800">
+		<div v-if="clip">
+			<div class="okzinsic _panel">
+				<div v-if="clip.description" class="description">
+					<Mfm :text="clip.description" :is-note="false" :i="$i"/>
+				</div>
+				<div class="user">
+					<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
+				</div>
 			</div>
-			<div class="user">
-				<MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
-			</div>
-		</div>
 
-		<XNotes :pagination="pagination" :detail="true"/>
-	</div>
-</MkSpacer>
+			<XNotes :pagination="pagination" :detail="true"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -22,7 +25,7 @@ import XNotes from '@/components/notes.vue';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	clipId: string,
@@ -49,59 +52,58 @@ watch(() => props.clipId, async () => {
 
 provide('currentClipPage', $$(clip));
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => clip ? {
-		title: clip.name,
-		icon: 'fas fa-paperclip',
-		bg: 'var(--bg)',
-		actions: isOwned ? [{
-			icon: 'fas fa-pencil-alt',
-			text: i18n.ts.edit,
-			handler: async (): Promise<void> => {
-				const { canceled, result } = await os.form(clip.name, {
-					name: {
-						type: 'string',
-						label: i18n.ts.name,
-						default: clip.name,
-					},
-					description: {
-						type: 'string',
-						required: false,
-						multiline: true,
-						label: i18n.ts.description,
-						default: clip.description,
-					},
-					isPublic: {
-						type: 'boolean',
-						label: i18n.ts.public,
-						default: clip.isPublic,
-					},
-				});
-				if (canceled) return;
-
-				os.apiWithDialog('clips/update', {
-					clipId: clip.id,
-					...result,
-				});
+const headerActions = $computed(() => clip && isOwned ? [{
+	icon: 'fas fa-pencil-alt',
+	text: i18n.ts.edit,
+	handler: async (): Promise<void> => {
+		const { canceled, result } = await os.form(clip.name, {
+			name: {
+				type: 'string',
+				label: i18n.ts.name,
+				default: clip.name,
 			},
-		}, {
-			icon: 'fas fa-trash-alt',
-			text: i18n.ts.delete,
-			danger: true,
-			handler: async (): Promise<void> => {
-				const { canceled } = await os.confirm({
-					type: 'warning',
-					text: i18n.t('deleteAreYouSure', { x: clip.name }),
-				});
-				if (canceled) return;
-
-				await os.apiWithDialog('clips/delete', {
-					clipId: clip.id,
-				});
+			description: {
+				type: 'string',
+				required: false,
+				multiline: true,
+				label: i18n.ts.description,
+				default: clip.description,
 			},
-		}] : [],
-	} : null),
-});
+			isPublic: {
+				type: 'boolean',
+				label: i18n.ts.public,
+				default: clip.isPublic,
+			},
+		});
+		if (canceled) return;
+
+		os.apiWithDialog('clips/update', {
+			clipId: clip.id,
+			...result,
+		});
+	},
+}, {
+	icon: 'fas fa-trash-alt',
+	text: i18n.ts.delete,
+	danger: true,
+	handler: async (): Promise<void> => {
+		const { canceled } = await os.confirm({
+			type: 'warning',
+			text: i18n.t('deleteAreYouSure', { x: clip.name }),
+		});
+		if (canceled) return;
+
+		await os.apiWithDialog('clips/delete', {
+			clipId: clip.id,
+		});
+	},
+}] : null);
+
+definePageMetadata(computed(() => clip ? {
+	title: clip.name,
+	icon: 'fas fa-paperclip',
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue
index 68777bb083..c7bc31135c 100644
--- a/packages/client/src/pages/drive.vue
+++ b/packages/client/src/pages/drive.vue
@@ -8,17 +8,19 @@
 import { computed } from 'vue';
 import XDrive from '@/components/drive.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let folder = $ref(null);
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: folder ? folder.name : i18n.ts.drive,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-		hideHeader: true,
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: folder ? folder.name : i18n.ts.drive,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
+	hideHeader: true,
+})));
 </script>
diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/emojis.category.vue
index c47870f4d4..6d915c5843 100644
--- a/packages/client/src/pages/emojis.category.vue
+++ b/packages/client/src/pages/emojis.category.vue
@@ -36,7 +36,6 @@ import MkSelect from '@/components/form/select.vue';
 import MkFolder from '@/components/ui/folder.vue';
 import MkTab from '@/components/tab.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { emojiCategories, emojiTags } from '@/instance';
 import XEmoji from './emojis.emoji.vue';
 
diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue
index f44b29df04..1592995844 100644
--- a/packages/client/src/pages/emojis.vue
+++ b/packages/client/src/pages/emojis.vue
@@ -1,15 +1,18 @@
 <template>
-<div :class="$style.root">
-	<XCategory v-if="tab === 'category'"/>
-</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div :class="$style.root">
+		<XCategory v-if="tab === 'category'"/>
+	</div>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { ref, computed } from 'vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
 import XCategory from './emojis.category.vue';
+import * as os from '@/os';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const tab = ref('category');
 
@@ -31,20 +34,21 @@ function menu(ev) {
 					text: err.message,
 				});
 			});
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.customEmojis,
-		icon: 'fas fa-laugh',
-		bg: 'var(--bg)',
-		actions: [{
-			icon: 'fas fa-ellipsis-h',
-			handler: menu,
-		}],
-	},
+const headerActions = $computed(() => [{
+	icon: 'fas fa-ellipsis-h',
+	handler: menu,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.customEmojis,
+	icon: 'fas fa-laugh',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue
index 04cc3662a7..26e201cd99 100644
--- a/packages/client/src/pages/explore.vue
+++ b/packages/client/src/pages/explore.vue
@@ -1,11 +1,12 @@
 <template>
-<div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
 	<MkSpacer :content-max="1200">
 		<div class="lznhrdub">
 			<div v-if="tab === 'local'">
-				<div v-if="meta && stats && tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }">
-					<header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header>
-					<div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div>
+				<div v-if="instance && stats && tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: instance.bannerUrl ? `url(${instance.bannerUrl})` : null }">
+					<header><span>{{ $t('explore', { host: instance.name || 'Misskey' }) }}</span></header>
+					<div><span>{{ $t('exploreUsersCount', { count: number(stats.originalUsersCount) }) }}</span></div>
 				</div>
 
 				<template v-if="tag == null">
@@ -32,7 +33,7 @@
 					<header><span>{{ $ts.exploreFediverse }}</span></header>
 				</div>
 
-				<MkFolder ref="tags" :foldable="true" :expanded="false" class="_gap">
+				<MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap">
 					<template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template>
 
 					<div class="vxjfqztj">
@@ -74,147 +75,127 @@
 					</MkRadios>
 				</div>
 
-				<XUserList v-if="searchQuery" ref="search" class="_gap" :pagination="searchPagination"/>
+				<XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/>
 			</div>
 		</div>
 	</MkSpacer>
-</div>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
 import XUserList from '@/components/user-list.vue';
 import MkFolder from '@/components/ui/folder.vue';
 import MkInput from '@/components/form/input.vue';
 import MkRadios from '@/components/form/radios.vue';
 import number from '@/filters/number';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		XUserList,
-		MkFolder,
-		MkInput,
-		MkRadios,
-	},
-
-	props: {
-		tag: {
-			type: String,
-			required: false
-		}
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.explore,
-				icon: 'fas fa-hashtag',
-				bg: 'var(--bg)',
-				tabs: [{
-					active: this.tab === 'local',
-					title: this.$ts.local,
-					onClick: () => { this.tab = 'local'; },
-				}, {
-					active: this.tab === 'remote',
-					title: this.$ts.remote,
-					onClick: () => { this.tab = 'remote'; },
-				}, {
-					active: this.tab === 'search',
-					title: this.$ts.search,
-					onClick: () => { this.tab = 'search'; },
-				},]
-			})),
-			tab: 'local',
-			pinnedUsers: { endpoint: 'pinned-users' },
-			popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				state: 'alive',
-				origin: 'local',
-				sort: '+follower',
-			} },
-			recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'local',
-				sort: '+updatedAt',
-			} },
-			recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'local',
-				state: 'alive',
-				sort: '+createdAt',
-			} },
-			popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				state: 'alive',
-				origin: 'remote',
-				sort: '+follower',
-			} },
-			recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'combined',
-				sort: '+updatedAt',
-			} },
-			recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
-				origin: 'combined',
-				sort: '+createdAt',
-			} },
-			searchPagination: {
-				endpoint: 'users/search' as const,
-				limit: 10,
-				params: computed(() => (this.searchQuery && this.searchQuery !== '') ? {
-					query: this.searchQuery,
-					origin: this.searchOrigin,
-				} : null)
-			},
-			tagsLocal: [],
-			tagsRemote: [],
-			stats: null,
-			searchQuery: null,
-			searchOrigin: 'combined',
-			num: number,
-		};
-	},
-
-	computed: {
-		meta() {
-			return this.$instance;
-		},
-		tagUsers(): any {
-			return {
-				endpoint: 'hashtags/users' as const,
-				limit: 30,
-				params: {
-					tag: this.tag,
-					origin: 'combined',
-					sort: '+follower',
-				}
-			};
-		},
-	},
-
-	watch: {
-		tag() {
-			if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
-		},
-	},
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+
+const props = defineProps<{
+	tag?: string;
+}>();
+
+let tab = $ref('local');
+let tagsEl = $ref<InstanceType<typeof MkFolder>>();
+let tagsLocal = $ref([]);
+let tagsRemote = $ref([]);
+let stats = $ref(null);
+let searchQuery = $ref(null);
+let searchOrigin = $ref('combined');
+
+watch(() => props.tag, () => {
+	if (tagsEl) tagsEl.toggleContent(props.tag == null);
+});
 
-	created() {
-		os.api('hashtags/list', {
-			sort: '+attachedLocalUsers',
-			attachedToLocalUserOnly: true,
-			limit: 30
-		}).then(tags => {
-			this.tagsLocal = tags;
-		});
-		os.api('hashtags/list', {
-			sort: '+attachedRemoteUsers',
-			attachedToRemoteUserOnly: true,
-			limit: 30
-		}).then(tags => {
-			this.tagsRemote = tags;
-		});
-		os.api('stats').then(stats => {
-			this.stats = stats;
-		});
+const tagUsers = $computed(() => ({
+	endpoint: 'hashtags/users' as const,
+	limit: 30,
+	params: {
+		tag: props.tag,
+		origin: 'combined',
+		sort: '+follower',
 	},
+}));
+
+const pinnedUsers = { endpoint: 'pinned-users' };
+const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	state: 'alive',
+	origin: 'local',
+	sort: '+follower',
+} };
+const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'local',
+	sort: '+updatedAt',
+} };
+const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'local',
+	state: 'alive',
+	sort: '+createdAt',
+} };
+const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	state: 'alive',
+	origin: 'remote',
+	sort: '+follower',
+} };
+const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'combined',
+	sort: '+updatedAt',
+} };
+const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: {
+	origin: 'combined',
+	sort: '+createdAt',
+} };
+const searchPagination = {
+	endpoint: 'users/search' as const,
+	limit: 10,
+	params: computed(() => (searchQuery && searchQuery !== '') ? {
+		query: searchQuery,
+		origin: searchOrigin,
+	} : null),
+};
+
+os.api('hashtags/list', {
+	sort: '+attachedLocalUsers',
+	attachedToLocalUserOnly: true,
+	limit: 30,
+}).then(tags => {
+	tagsLocal = tags;
 });
+os.api('hashtags/list', {
+	sort: '+attachedRemoteUsers',
+	attachedToRemoteUserOnly: true,
+	limit: 30,
+}).then(tags => {
+	tagsRemote = tags;
+});
+os.api('stats').then(_stats => {
+	stats = _stats;
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'local',
+	title: i18n.ts.local,
+	onClick: () => { tab = 'local'; },
+}, {
+	active: tab === 'remote',
+	title: i18n.ts.remote,
+	onClick: () => { tab = 'remote'; },
+}, {
+	active: tab === 'search',
+	title: i18n.ts.search,
+	onClick: () => { tab = 'search'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.explore,
+	icon: 'fas fa-hashtag',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue
index b4f6ff35bc..6efca4c221 100644
--- a/packages/client/src/pages/favorites.vue
+++ b/packages/client/src/pages/favorites.vue
@@ -1,20 +1,23 @@
 <template>
-<MkSpacer :content-max="800">
-	<MkPagination ref="pagingComponent" :pagination="pagination">
-		<template #empty>
-			<div class="_fullinfo">
-				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-				<div>{{ $ts.noNotes }}</div>
-			</div>
-		</template>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="800">
+		<MkPagination ref="pagingComponent" :pagination="pagination">
+			<template #empty>
+				<div class="_fullinfo">
+					<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+					<div>{{ $ts.noNotes }}</div>
+				</div>
+			</template>
 
-		<template #default="{ items }">
-			<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
-				<XNote :key="item.id" :note="item.note" :class="$style.note"/>
-			</XList>
-		</template>
-	</MkPagination>
-</MkSpacer>
+			<template #default="{ items }">
+				<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
+					<XNote :key="item.id" :note="item.note" :class="$style.note"/>
+				</XList>
+			</template>
+		</MkPagination>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -22,8 +25,8 @@ import { ref } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import XNote from '@/components/note.vue';
 import XList from '@/components/date-separated-list.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'i/favorites' as const,
@@ -32,12 +35,10 @@ const pagination = {
 
 const pagingComponent = ref<InstanceType<typeof MkPagination>>();
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.favorites,
-		icon: 'fas fa-star',
-		bg: 'var(--bg)',
-	},
+definePageMetadata({
+	title: i18n.ts.favorites,
+	icon: 'fas fa-star',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue
index 14fe0cb740..4e3f67c76c 100644
--- a/packages/client/src/pages/featured.vue
+++ b/packages/client/src/pages/featured.vue
@@ -1,13 +1,16 @@
 <template>
-<MkSpacer :content-max="800">
-	<XNotes ref="notes" :pagination="pagination"/>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<MkSpacer :content-max="800">
+		<XNotes ref="notes" :pagination="pagination"/>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'notes/featured' as const,
@@ -15,11 +18,9 @@ const pagination = {
 	offsetMode: true,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.featured,
-		icon: 'fas fa-fire-alt',
-		bg: 'var(--bg)',
-	},
+definePageMetadata({
+	title: i18n.ts.featured,
+	icon: 'fas fa-fire-alt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue
index 447918905b..eda46b9aa3 100644
--- a/packages/client/src/pages/federation.vue
+++ b/packages/client/src/pages/federation.vue
@@ -1,94 +1,97 @@
 <template>
-<MkSpacer :content-max="1000">
-	<div class="taeiyria">
-		<div class="query">
-			<MkInput v-model="host" :debounce="true" class="">
-				<template #prefix><i class="fas fa-search"></i></template>
-				<template #label>{{ $ts.host }}</template>
-			</MkInput>
-			<FormSplit style="margin-top: var(--margin);">
-				<MkSelect v-model="state">
-					<template #label>{{ $ts.state }}</template>
-					<option value="all">{{ $ts.all }}</option>
-					<option value="federating">{{ $ts.federating }}</option>
-					<option value="subscribing">{{ $ts.subscribing }}</option>
-					<option value="publishing">{{ $ts.publishing }}</option>
-					<option value="suspended">{{ $ts.suspended }}</option>
-					<option value="blocked">{{ $ts.blocked }}</option>
-					<option value="notResponding">{{ $ts.notResponding }}</option>
-				</MkSelect>
-				<MkSelect v-model="sort">
-					<template #label>{{ $ts.sort }}</template>
-					<option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
-					<option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
-					<option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
-					<option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
-					<option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
-					<option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
-					<option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
-					<option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
-					<option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
-				</MkSelect>
-			</FormSplit>
-		</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1000">
+		<div class="taeiyria">
+			<div class="query">
+				<MkInput v-model="host" :debounce="true" class="">
+					<template #prefix><i class="fas fa-search"></i></template>
+					<template #label>{{ $ts.host }}</template>
+				</MkInput>
+				<FormSplit style="margin-top: var(--margin);">
+					<MkSelect v-model="state">
+						<template #label>{{ $ts.state }}</template>
+						<option value="all">{{ $ts.all }}</option>
+						<option value="federating">{{ $ts.federating }}</option>
+						<option value="subscribing">{{ $ts.subscribing }}</option>
+						<option value="publishing">{{ $ts.publishing }}</option>
+						<option value="suspended">{{ $ts.suspended }}</option>
+						<option value="blocked">{{ $ts.blocked }}</option>
+						<option value="notResponding">{{ $ts.notResponding }}</option>
+					</MkSelect>
+					<MkSelect v-model="sort">
+						<template #label>{{ $ts.sort }}</template>
+						<option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
+						<option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
+						<option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
+						<option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
+						<option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
+						<option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
+						<option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
+						<option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
+						<option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
+					</MkSelect>
+				</FormSplit>
+			</div>
 
-		<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
-			<div class="dqokceoi">
-				<MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`">
-					<div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div>
-					<div class="table">
-						<div class="cell">
-							<div class="key">{{ $ts.registeredAt }}</div>
-							<div class="value"><MkTime :time="instance.caughtAt"/></div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.software }}</div>
-							<div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.version }}</div>
-							<div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.users }}</div>
-							<div class="value">{{ instance.usersCount }}</div>
-						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.notes }}</div>
-							<div class="value">{{ instance.notesCount }}</div>
+			<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
+				<div class="dqokceoi">
+					<MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`">
+						<div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div>
+						<div class="table">
+							<div class="cell">
+								<div class="key">{{ $ts.registeredAt }}</div>
+								<div class="value"><MkTime :time="instance.caughtAt"/></div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.software }}</div>
+								<div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.version }}</div>
+								<div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.users }}</div>
+								<div class="value">{{ instance.usersCount }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.notes }}</div>
+								<div class="value">{{ instance.notesCount }}</div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.sent }}</div>
+								<div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+							</div>
+							<div class="cell">
+								<div class="key">{{ $ts.received }}</div>
+								<div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+							</div>
 						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.sent }}</div>
-							<div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+						<div class="footer">
+							<span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span>
+							<span class="pubSub">
+								<span v-if="instance.followersCount > 0" class="sub"><i class="fas fa-caret-down icon"></i>Sub</span>
+								<span v-else class="sub"><i class="fas fa-caret-down icon"></i>-</span>
+								<span v-if="instance.followingCount > 0" class="pub"><i class="fas fa-caret-up icon"></i>Pub</span>
+								<span v-else class="pub"><i class="fas fa-caret-up icon"></i>-</span>
+							</span>
+							<span class="right">
+								<span class="latestStatus">{{ instance.latestStatus || '-' }}</span>
+								<span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span>
+							</span>
 						</div>
-						<div class="cell">
-							<div class="key">{{ $ts.received }}</div>
-							<div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
-						</div>
-					</div>
-					<div class="footer">
-						<span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span>
-						<span class="pubSub">
-							<span v-if="instance.followersCount > 0" class="sub"><i class="fas fa-caret-down icon"></i>Sub</span>
-							<span v-else class="sub"><i class="fas fa-caret-down icon"></i>-</span>
-							<span v-if="instance.followingCount > 0" class="pub"><i class="fas fa-caret-up icon"></i>Pub</span>
-							<span v-else class="pub"><i class="fas fa-caret-up icon"></i>-</span>
-						</span>
-						<span class="right">
-							<span class="latestStatus">{{ instance.latestStatus || '-' }}</span>
-							<span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span>
-						</span>
-					</div>
-				</MkA>
-			</div>
-		</MkPagination>
-	</div>
-</MkSpacer>
+					</MkA>
+				</div>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -99,8 +102,8 @@ import MkSelect from '@/components/form/select.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let host = $ref('');
 let state = $ref('federating');
@@ -119,8 +122,8 @@ const pagination = {
 			state === 'suspended' ? { suspended: true } :
 			state === 'blocked' ? { blocked: true } :
 			state === 'notResponding' ? { notResponding: true } :
-			{})
-	}))
+			{}),
+	})),
 };
 
 function getStatus(instance) {
@@ -129,12 +132,14 @@ function getStatus(instance) {
 	return 'alive';
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.federation,
-		icon: 'fas fa-globe',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.federation,
+	icon: 'fas fa-globe',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue
index 6adc1a404b..e6f9a9a5d9 100644
--- a/packages/client/src/pages/follow-requests.vue
+++ b/packages/client/src/pages/follow-requests.vue
@@ -7,7 +7,7 @@
 				<div>{{ $ts.noFollowRequests }}</div>
 			</div>
 		</template>
-		<template v-slot="{items}">
+		<template #default="{items}">
 			<div class="mk-follow-requests">
 				<div v-for="req in items" :key="req.id" class="user _panel">
 					<MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
@@ -36,8 +36,8 @@ import { ref, computed } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import { userPage, acct } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const paginationComponent = ref<InstanceType<typeof MkPagination>>();
 
@@ -58,13 +58,15 @@ function reject(user) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.followRequests,
-		icon: 'fas fa-user-clock',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.followRequests,
+	icon: 'fas fa-user-clock',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue
index e69e0481e0..0c1cb7733b 100644
--- a/packages/client/src/pages/follow.vue
+++ b/packages/client/src/pages/follow.vue
@@ -5,8 +5,9 @@
 
 <script lang="ts">
 import { defineComponent } from 'vue';
-import * as os from '@/os';
 import * as Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
 
 export default defineComponent({
 	created() {
@@ -17,17 +18,17 @@ export default defineComponent({
 
 		if (acct.startsWith('https://')) {
 			promise = os.api('ap/show', {
-				uri: acct
+				uri: acct,
 			});
 			promise.then(res => {
 				if (res.type === 'User') {
 					this.follow(res.object);
 				} else if (res.type === 'Note') {
-					this.$router.push(`/notes/${res.object.id}`);
+					mainRouter.push(`/notes/${res.object.id}`);
 				} else {
 					os.alert({
 						type: 'error',
-						text: 'Not a user'
+						text: 'Not a user',
 					}).then(() => {
 						window.close();
 					});
@@ -56,9 +57,9 @@ export default defineComponent({
 			}
 			
 			os.apiWithDialog('following/create', {
-				userId: user.id
+				userId: user.id,
 			});
-		}
-	}
+		},
+	},
 });
 </script>
diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue
index bc87160c44..6d1140ba3a 100644
--- a/packages/client/src/pages/gallery/edit.vue
+++ b/packages/client/src/pages/gallery/edit.vue
@@ -27,8 +27,8 @@
 </div>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject, watch } from 'vue';
 import FormButton from '@/components/ui/button.vue';
 import FormInput from '@/components/form/input.vue';
 import FormTextarea from '@/components/form/textarea.vue';
@@ -37,104 +37,87 @@ import FormGroup from '@/components/form/group.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import { selectFiles } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		FormButton,
-		FormInput,
-		FormTextarea,
-		FormSwitch,
-		FormGroup,
-		FormSuspense,
-	},
-
-	props: {
-		postId: {
-			type: String,
-			required: false,
-			default: null,
-		}
-	},
-	
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.postId ? {
-				title: this.$ts.edit,
-				icon: 'fas fa-pencil-alt'
-			} : {
-				title: this.$ts.postToGallery,
-				icon: 'fas fa-pencil-alt'
-			}),
-			init: null,
-			files: [],
-			description: null,
-			title: null,
-			isSensitive: false,
-		};
-	},
-
-	watch: {
-		postId: {
-			handler() {
-				this.init = () => this.postId ? os.api('gallery/posts/show', {
-					postId: this.postId
-				}).then(post => {
-					this.files = post.files;
-					this.title = post.title;
-					this.description = post.description;
-					this.isSensitive = post.isSensitive;
-				}) : Promise.resolve(null);
-			},
-			immediate: true,
-		}
-	},
-
-	methods: {
-		selectFile(evt) {
-			selectFiles(evt.currentTarget ?? evt.target, null).then(files => {
-				this.files = this.files.concat(files);
-			});
-		},
-
-		remove(file) {
-			this.files = this.files.filter(f => f.id !== file.id);
-		},
-
-		async save() {
-			if (this.postId) {
-				await os.apiWithDialog('gallery/posts/update', {
-					postId: this.postId,
-					title: this.title,
-					description: this.description,
-					fileIds: this.files.map(file => file.id),
-					isSensitive: this.isSensitive,
-				});
-				this.$router.push(`/gallery/${this.postId}`);
-			} else {
-				const post = await os.apiWithDialog('gallery/posts/create', {
-					title: this.title,
-					description: this.description,
-					fileIds: this.files.map(file => file.id),
-					isSensitive: this.isSensitive,
-				});
-				this.$router.push(`/gallery/${post.id}`);
-			}
-		},
-
-		async del() {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$ts.deleteConfirm,
-			});
-			if (canceled) return;
-			await os.apiWithDialog('gallery/posts/delete', {
-				postId: this.postId,
-			});
-			this.$router.push(`/gallery`);
-		}
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const router = useRouter();
+
+const props = defineProps<{
+	postId?: string;
+}>();
+
+let init = $ref(null);
+let files = $ref([]);
+let description = $ref(null);
+let title = $ref(null);
+let isSensitive = $ref(false);
+
+function selectFile(evt) {
+	selectFiles(evt.currentTarget ?? evt.target, null).then(selected => {
+		files = files.concat(selected);
+	});
+}
+
+function remove(file) {
+	files = files.filter(f => f.id !== file.id);
+}
+
+async function save() {
+	if (props.postId) {
+		await os.apiWithDialog('gallery/posts/update', {
+			postId: props.postId,
+			title: title,
+			description: description,
+			fileIds: files.map(file => file.id),
+			isSensitive: isSensitive,
+		});
+		mainRouter.push(`/gallery/${props.postId}`);
+	} else {
+		const created = await os.apiWithDialog('gallery/posts/create', {
+			title: title,
+			description: description,
+			fileIds: files.map(file => file.id),
+			isSensitive: isSensitive,
+		});
+		router.push(`/gallery/${created.id}`);
 	}
-});
+}
+
+async function del() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteConfirm,
+	});
+	if (canceled) return;
+	await os.apiWithDialog('gallery/posts/delete', {
+		postId: props.postId,
+	});
+	mainRouter.push('/gallery');
+}
+
+watch(() => props.postId, () => {
+	init = () => props.postId ? os.api('gallery/posts/show', {
+		postId: props.postId,
+	}).then(post => {
+		files = post.files;
+		title = post.title;
+		description = post.description;
+		isSensitive = post.isSensitive;
+	}) : Promise.resolve(null);
+}, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => props.postId ? {
+	title: i18n.ts.edit,
+	icon: 'fas fa-pencil-alt',
+} : {
+	title: i18n.ts.postToGallery,
+	icon: 'fas fa-pencil-alt',
+}));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue
index a19d69d5c2..b26470dbe9 100644
--- a/packages/client/src/pages/gallery/index.vue
+++ b/packages/client/src/pages/gallery/index.vue
@@ -1,49 +1,54 @@
 <template>
-<div class="xprsixdl _root">
-	<MkTab v-if="$i" v-model="tab">
-		<option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
-		<option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
-		<option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
-	</MkTab>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="1400">
+		<div class="_root">
+			<MkTab v-if="$i" v-model="tab">
+				<option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
+				<option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
+				<option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
+			</MkTab>
 
-	<div v-if="tab === 'explore'">
-		<MkFolder class="_gap">
-			<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
-			<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
-				<div class="vfpdbgtk">
-					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-				</div>
-			</MkPagination>
-		</MkFolder>
-		<MkFolder class="_gap">
-			<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
-			<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
-				<div class="vfpdbgtk">
-					<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
-				</div>
-			</MkPagination>
-		</MkFolder>
-	</div>
-	<div v-else-if="tab === 'liked'">
-		<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
-			<div class="vfpdbgtk">
-				<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
+			<div v-if="tab === 'explore'">
+				<MkFolder class="_gap">
+					<template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
+					<MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true">
+						<div class="vfpdbgtk">
+							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+						</div>
+					</MkPagination>
+				</MkFolder>
+				<MkFolder class="_gap">
+					<template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
+					<MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true">
+						<div class="vfpdbgtk">
+							<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+						</div>
+					</MkPagination>
+				</MkFolder>
 			</div>
-		</MkPagination>
-	</div>
-	<div v-else-if="tab === 'my'">
-		<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
-		<MkPagination v-slot="{items}" :pagination="myPostsPagination">
-			<div class="vfpdbgtk">
-				<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+			<div v-else-if="tab === 'liked'">
+				<MkPagination v-slot="{items}" :pagination="likedPostsPagination">
+					<div class="vfpdbgtk">
+						<MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/>
+					</div>
+				</MkPagination>
 			</div>
-		</MkPagination>
-	</div>
-</div>
+			<div v-else-if="tab === 'my'">
+				<MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
+				<MkPagination v-slot="{items}" :pagination="myPostsPagination">
+					<div class="vfpdbgtk">
+						<MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/>
+					</div>
+				</MkPagination>
+			</div>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
 import XUserList from '@/components/user-list.vue';
 import MkFolder from '@/components/ui/folder.vue';
 import MkInput from '@/components/form/input.vue';
@@ -53,92 +58,60 @@ import MkPagination from '@/components/ui/pagination.vue';
 import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
 import number from '@/filters/number';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		XUserList,
-		MkFolder,
-		MkInput,
-		MkButton,
-		MkTab,
-		MkPagination,
-		MkGalleryPostPreview,
-	},
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-	props: {
-		tag: {
-			type: String,
-			required: false
-		}
-	},
+const props = defineProps<{
+	tag?: string;
+}>();
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.gallery,
-				icon: 'fas fa-icons'
-			},
-			tab: 'explore',
-			recentPostsPagination: {
-				endpoint: 'gallery/posts' as const,
-				limit: 6,
-			},
-			popularPostsPagination: {
-				endpoint: 'gallery/featured' as const,
-				limit: 5,
-			},
-			myPostsPagination: {
-				endpoint: 'i/gallery/posts' as const,
-				limit: 5,
-			},
-			likedPostsPagination: {
-				endpoint: 'i/gallery/likes' as const,
-				limit: 5,
-			},
-			tags: [],
-		};
-	},
+let tab = $ref('explore');
+let tags = $ref([]);
+let tagsRef = $ref();
 
-	computed: {
-		meta() {
-			return this.$instance;
-		},
-		tagUsers(): any {
-			return {
-				endpoint: 'hashtags/users' as const,
-				limit: 30,
-				params: {
-					tag: this.tag,
-					origin: 'combined',
-					sort: '+follower',
-				}
-			};
-		},
-	},
+const recentPostsPagination = {
+	endpoint: 'gallery/posts' as const,
+	limit: 6,
+};
+const popularPostsPagination = {
+	endpoint: 'gallery/featured' as const,
+	limit: 5,
+};
+const myPostsPagination = {
+	endpoint: 'i/gallery/posts' as const,
+	limit: 5,
+};
+const likedPostsPagination = {
+	endpoint: 'i/gallery/likes' as const,
+	limit: 5,
+};
 
-	watch: {
-		tag() {
-			if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
-		},
+const tagUsersPagination = $computed(() => ({
+	endpoint: 'hashtags/users' as const,
+	limit: 30,
+	params: {
+		tag: this.tag,
+		origin: 'combined',
+		sort: '+follower',
 	},
+}));
 
-	created() {
+watch(() => props.tag, () => {
+	if (tagsRef) tagsRef.tags.toggleContent(props.tag == null);
+});
 
-	},
+const headerActions = $computed(() => []);
 
-	methods: {
+const headerTabs = $computed(() => []);
 
-	}
+definePageMetadata({
+	title: i18n.ts.gallery,
+	icon: 'fas fa-icons',
+	bg: 'var(--bg)',
 });
 </script>
 
 <style lang="scss" scoped>
-.xprsixdl {
-	max-width: 1400px;
-	margin: 0 auto;
-}
-
 .vfpdbgtk {
 	display: grid;
 	grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue
index 1ca3443e56..d5f3253b3e 100644
--- a/packages/client/src/pages/gallery/post.vue
+++ b/packages/client/src/pages/gallery/post.vue
@@ -49,123 +49,108 @@
 </div>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, inject, watch } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import MkContainer from '@/components/ui/container.vue';
 import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
 import MkFollowButton from '@/components/follow-button.vue';
 import { url } from '@/config';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		ImgWithBlurhash,
-		MkPagination,
-		MkGalleryPostPreview,
-		MkButton,
-		MkFollowButton,
-	},
-	props: {
-		postId: {
-			type: String,
-			required: true
-		}
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.post ? {
-				title: this.post.title,
-				avatar: this.post.user,
-				path: `/gallery/${this.post.id}`,
-				share: {
-					title: this.post.title,
-					text: this.post.description,
-				},
-				actions: [{
-					icon: 'fas fa-pencil-alt',
-					text: this.$ts.edit,
-					handler: this.edit
-				}]
-			} : null),
-			otherPostsPagination: {
-				endpoint: 'users/gallery/posts' as const,
-				limit: 6,
-				params: computed(() => ({
-					userId: this.post.user.id
-				})),
-			},
-			post: null,
-			error: null,
-		};
-	},
+const router = useRouter();
 
-	watch: {
-		postId: 'fetch'
-	},
+const props = defineProps<{
+	postId: string;
+}>();
 
-	created() {
-		this.fetch();
-	},
+const post = $ref(null);
+const error = $ref(null);
+const otherPostsPagination = {
+	endpoint: 'users/gallery/posts' as const,
+	limit: 6,
+	params: computed(() => ({
+		userId: post.user.id,
+	})),
+};
 
-	methods: {
-		fetch() {
-			this.post = null;
-			os.api('gallery/posts/show', {
-				postId: this.postId
-			}).then(post => {
-				this.post = post;
-			}).catch(err => {
-				this.error = err;
-			});
-		},
-
-		share() {
-			navigator.share({
-				title: this.post.title,
-				text: this.post.description,
-				url: `${url}/gallery/${this.post.id}`
-			});
-		},
-
-		shareWithNote() {
-			os.post({
-				initialText: `${this.post.title} ${url}/gallery/${this.post.id}`
-			});
-		},
-
-		like() {
-			os.apiWithDialog('gallery/posts/like', {
-				postId: this.postId,
-			}).then(() => {
-				this.post.isLiked = true;
-				this.post.likedCount++;
-			});
-		},
-
-		async unlike() {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: this.$ts.unlikeConfirm,
-			});
-			if (confirm.canceled) return;
-			os.apiWithDialog('gallery/posts/unlike', {
-				postId: this.postId,
-			}).then(() => {
-				this.post.isLiked = false;
-				this.post.likedCount--;
-			});
-		},
-
-		edit() {
-			this.$router.push(`/gallery/${this.post.id}/edit`);
-		}
-	}
-});
+function fetchPost() {
+	post = null;
+	os.api('gallery/posts/show', {
+		postId: props.postId,
+	}).then(_post => {
+		post = _post;
+	}).catch(_error => {
+		error = _error;
+	});
+}
+
+function share() {
+	navigator.share({
+		title: post.title,
+		text: post.description,
+		url: `${url}/gallery/${post.id}`,
+	});
+}
+
+function shareWithNote() {
+	os.post({
+		initialText: `${post.title} ${url}/gallery/${post.id}`,
+	});
+}
+
+function like() {
+	os.apiWithDialog('gallery/posts/like', {
+		postId: props.postId,
+	}).then(() => {
+		post.isLiked = true;
+		post.likedCount++;
+	});
+}
+
+async function unlike() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unlikeConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('gallery/posts/unlike', {
+		postId: props.postId,
+	}).then(() => {
+		post.isLiked = false;
+		post.likedCount--;
+	});
+}
+
+function edit() {
+	router.push(`/gallery/${post.id}/edit`);
+}
+
+watch(() => props.postId, fetchPost, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => post ? {
+	title: post.title,
+	avatar: post.user,
+	path: `/gallery/${post.id}`,
+	share: {
+		title: post.title,
+		text: post.description,
+	},
+	actions: [{
+		icon: 'fas fa-pencil-alt',
+		text: i18n.ts.edit,
+		handler: edit,
+	}],
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue
index f19cb9d1a2..31d3d4ac83 100644
--- a/packages/client/src/pages/instance-info.vue
+++ b/packages/client/src/pages/instance-info.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
 	<div v-if="instance" class="_formRoot">
 		<div class="fnfelxur">
 			<img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/>
@@ -102,7 +103,7 @@
 			<FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink>
 		</FormSection>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -120,8 +121,8 @@ import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { iAmModerator } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	host: string;
@@ -146,7 +147,7 @@ async function fetch() {
 async function toggleBlock(ev) {
 	if (meta == null) return;
 	await os.api('admin/update-meta', {
-		blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host)
+		blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host),
 	});
 }
 
@@ -168,19 +169,21 @@ function refreshMetadata() {
 
 fetch();
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: props.host,
-		icon: 'fas fa-info-circle',
-		bg: 'var(--bg)',
-		actions: [{
-			text: `https://${props.host}`,
-			icon: 'fas fa-external-link-alt',
-			handler: () => {
-				window.open(`https://${props.host}`, '_blank');
-			}
-		}],
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: props.host,
+	icon: 'fas fa-info-circle',
+	bg: 'var(--bg)',
+	actions: [{
+		text: `https://${props.host}`,
+		icon: 'fas fa-external-link-alt',
+		handler: () => {
+			window.open(`https://${props.host}`, '_blank');
+		},
+	}],
 });
 </script>
 
diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue
index 9b57c956bf..0835f1f019 100644
--- a/packages/client/src/pages/mentions.vue
+++ b/packages/client/src/pages/mentions.vue
@@ -1,24 +1,27 @@
-<template>
-<MkSpacer :content-max="800">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800">
 	<XNotes :pagination="pagination"/>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'notes/mentions' as const,
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.mentions,
-		icon: 'fas fa-at',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.mentions,
+	icon: 'fas fa-at',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue
index 9c5fb9b341..e443b5c461 100644
--- a/packages/client/src/pages/messages.vue
+++ b/packages/client/src/pages/messages.vue
@@ -1,27 +1,30 @@
-<template>
-<MkSpacer :content-max="800">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800">
 	<XNotes :pagination="pagination"/>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'notes/mentions' as const,
 	limit: 10,
 	params: {
-		visibility: 'specified'
+		visibility: 'specified',
 	},
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.directNotes,
-		icon: 'fas fa-envelope',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.directNotes,
+	icon: 'fas fa-envelope',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue
index 7c1d3e3cbe..bf9ac056cf 100644
--- a/packages/client/src/pages/messaging/index.vue
+++ b/packages/client/src/pages/messaging/index.vue
@@ -1,165 +1,165 @@
 <template>
-<MkSpacer :content-max="800">
-	<div v-size="{ max: [400] }" class="yweeujhr">
-		<MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
-
-		<div v-if="messages.length > 0" class="history">
-			<MkA v-for="(message, i) in messages"
-				:key="message.id"
-				v-anim="i"
-				class="message _block"
-				:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
-				:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
-				:data-index="i"
-			>
-				<div>
-					<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
-					<header v-if="message.groupId">
-						<span class="name">{{ message.group.name }}</span>
-						<MkTime :time="message.createdAt" class="time"/>
-					</header>
-					<header v-else>
-						<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
-						<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
-						<MkTime :time="message.createdAt" class="time"/>
-					</header>
-					<div class="body">
-						<p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div v-size="{ max: [400] }" class="yweeujhr">
+			<MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
+
+			<div v-if="messages.length > 0" class="history">
+				<MkA
+					v-for="(message, i) in messages"
+					:key="message.id"
+					v-anim="i"
+					class="message _block"
+					:class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
+					:to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
+					:data-index="i"
+				>
+					<div>
+						<MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
+						<header v-if="message.groupId">
+							<span class="name">{{ message.group.name }}</span>
+							<MkTime :time="message.createdAt" class="time"/>
+						</header>
+						<header v-else>
+							<span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
+							<span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
+							<MkTime :time="message.createdAt" class="time"/>
+						</header>
+						<div class="body">
+							<p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p>
+						</div>
 					</div>
-				</div>
-			</MkA>
+				</MkA>
+			</div>
+			<div v-if="!fetching && messages.length == 0" class="_fullinfo">
+				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+				<div>{{ $ts.noHistory }}</div>
+			</div>
+			<MkLoading v-if="fetching"/>
 		</div>
-		<div v-if="!fetching && messages.length == 0" class="_fullinfo">
-			<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
-			<div>{{ $ts.noHistory }}</div>
-		</div>
-		<MkLoading v-if="fetching"/>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+<script lang="ts" setup>
+import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue';
 import * as Acct from 'misskey-js/built/acct';
 import MkButton from '@/components/ui/button.vue';
 import { acct } from '@/filters/user';
 import * as os from '@/os';
 import { stream } from '@/stream';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkButton
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.messaging,
-				icon: 'fas fa-comments',
-				bg: 'var(--bg)',
-			},
-			fetching: true,
-			moreFetching: false,
-			messages: [],
-			connection: null,
-		};
-	},
-
-	mounted() {
-		this.connection = markRaw(stream.useChannel('messagingIndex'));
-
-		this.connection.on('message', this.onMessage);
-		this.connection.on('read', this.onRead);
-
-		os.api('messaging/history', { group: false }).then(userMessages => {
-			os.api('messaging/history', { group: true }).then(groupMessages => {
-				const messages = userMessages.concat(groupMessages);
-				messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
-				this.messages = messages;
-				this.fetching = false;
-			});
-		});
-	},
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
 
-	beforeUnmount() {
-		this.connection.dispose();
-	},
+const router = useRouter();
 
-	methods: {
-		getAcct: Acct.toString,
+let fetching = $ref(true);
+let moreFetching = $ref(false);
+let messages = $ref([]);
+let connection = $ref(null);
 
-		isMe(message) {
-			return message.userId === this.$i.id;
-		},
+const getAcct = Acct.toString;
 
-		onMessage(message) {
-			if (message.recipientId) {
-				this.messages = this.messages.filter(m => !(
-					(m.recipientId === message.recipientId && m.userId === message.userId) ||
-					(m.recipientId === message.userId && m.userId === message.recipientId)));
+function isMe(message) {
+	return message.userId === $i.id;
+}
 
-				this.messages.unshift(message);
-			} else if (message.groupId) {
-				this.messages = this.messages.filter(m => m.groupId !== message.groupId);
-				this.messages.unshift(message);
-			}
-		},
-
-		onRead(ids) {
-			for (const id of ids) {
-				const found = this.messages.find(m => m.id === id);
-				if (found) {
-					if (found.recipientId) {
-						found.isRead = true;
-					} else if (found.groupId) {
-						found.reads.push(this.$i.id);
-					}
-				}
-			}
-		},
-
-		start(ev) {
-			os.popupMenu([{
-				text: this.$ts.messagingWithUser,
-				icon: 'fas fa-user',
-				action: () => { this.startUser(); }
-			}, {
-				text: this.$ts.messagingWithGroup,
-				icon: 'fas fa-users',
-				action: () => { this.startGroup(); }
-			}], ev.currentTarget ?? ev.target);
-		},
-
-		async startUser() {
-			os.selectUser().then(user => {
-				this.$router.push(`/my/messaging/${Acct.toString(user)}`);
-			});
-		},
-
-		async startGroup() {
-			const groups1 = await os.api('users/groups/owned');
-			const groups2 = await os.api('users/groups/joined');
-			if (groups1.length === 0 && groups2.length === 0) {
-				os.alert({
-					type: 'warning',
-					title: this.$ts.youHaveNoGroups,
-					text: this.$ts.joinOrCreateGroup,
-				});
-				return;
+function onMessage(message) {
+	if (message.recipientId) {
+		messages = messages.filter(m => !(
+			(m.recipientId === message.recipientId && m.userId === message.userId) ||
+			(m.recipientId === message.userId && m.userId === message.recipientId)));
+
+		messages.unshift(message);
+	} else if (message.groupId) {
+		messages = messages.filter(m => m.groupId !== message.groupId);
+		messages.unshift(message);
+	}
+}
+
+function onRead(ids) {
+	for (const id of ids) {
+		const found = messages.find(m => m.id === id);
+		if (found) {
+			if (found.recipientId) {
+				found.isRead = true;
+			} else if (found.groupId) {
+				found.reads.push($i.id);
 			}
-			const { canceled, result: group } = await os.select({
-				title: this.$ts.group,
-				items: groups1.concat(groups2).map(group => ({
-					value: group, text: group.name
-				}))
-			});
-			if (canceled) return;
-			this.$router.push(`/my/messaging/group/${group.id}`);
-		},
-
-		acct
+		}
 	}
+}
+
+function start(ev) {
+	os.popupMenu([{
+		text: i18n.ts.messagingWithUser,
+		icon: 'fas fa-user',
+		action: () => { startUser(); },
+	}, {
+		text: i18n.ts.messagingWithGroup,
+		icon: 'fas fa-users',
+		action: () => { startGroup(); },
+	}], ev.currentTarget ?? ev.target);
+}
+
+async function startUser() {
+	os.selectUser().then(user => {
+		router.push(`/my/messaging/${Acct.toString(user)}`);
+	});
+}
+
+async function startGroup() {
+	const groups1 = await os.api('users/groups/owned');
+	const groups2 = await os.api('users/groups/joined');
+	if (groups1.length === 0 && groups2.length === 0) {
+		os.alert({
+			type: 'warning',
+			title: i18n.ts.youHaveNoGroups,
+			text: i18n.ts.joinOrCreateGroup,
+		});
+		return;
+	}
+	const { canceled, result: group } = await os.select({
+		title: i18n.ts.group,
+		items: groups1.concat(groups2).map(group => ({
+			value: group, text: group.name,
+		})),
+	});
+	if (canceled) return;
+	router.push(`/my/messaging/group/${group.id}`);
+}
+
+onMounted(() => {
+	connection = markRaw(stream.useChannel('messagingIndex'));
+
+	connection.on('message', onMessage);
+	connection.on('read', onRead);
+
+	os.api('messaging/history', { group: false }).then(userMessages => {
+		os.api('messaging/history', { group: true }).then(groupMessages => {
+			const _messages = userMessages.concat(groupMessages);
+			_messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+			messages = _messages;
+			fetching = false;
+		});
+	});
+});
+
+onUnmounted(() => {
+	if (connection) connection.dispose();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.messaging,
+	icon: 'fas fa-comments',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue
index 65c67e6354..2e00c3ab19 100644
--- a/packages/client/src/pages/messaging/messaging-room.vue
+++ b/packages/client/src/pages/messaging/messaging-room.vue
@@ -61,10 +61,10 @@ import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scrol
 import * as os from '@/os';
 import { stream } from '@/stream';
 import * as sound from '@/scripts/sound';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
 import { defaultStore } from '@/store';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	userAcct?: string;
@@ -280,15 +280,13 @@ onBeforeUnmount(() => {
 	if (scrollRemove) scrollRemove();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => !fetching ? user ? {
-		userName: user,
-		avatar: user,
-	} : {
-		title: group?.name,
-		icon: 'fas fa-users',
-	} : null),
-});
+definePageMetadata(computed(() => !fetching ? user ? {
+	userName: user,
+	avatar: user,
+} : {
+	title: group?.name,
+	icon: 'fas fa-users',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue
index 2c10494ede..2b92a69c4b 100644
--- a/packages/client/src/pages/mfm-cheat-sheet.vue
+++ b/packages/client/src/pages/mfm-cheat-sheet.vue
@@ -1,127 +1,129 @@
 <template>
-<div class="mwysmxbg">
-	<div class="_isolated">{{ $ts._mfm.intro }}</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.mention }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.mentionDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_mention"/>
-				<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+<MkStickyContainer>
+	<template #header><MkPageHeader/></template>
+	<div class="mwysmxbg">
+		<div class="_isolated">{{ $ts._mfm.intro }}</div>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.mention }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.mentionDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_mention"/>
+					<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.hashtag }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.hashtagDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_hashtag"/>
-				<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.hashtag }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.hashtagDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_hashtag"/>
+					<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.url }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.urlDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_url"/>
-				<MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.url }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.urlDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_url"/>
+					<MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.link }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.linkDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_link"/>
-				<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.link }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.linkDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_link"/>
+					<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.emoji }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.emojiDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_emoji"/>
-				<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.emoji }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.emojiDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_emoji"/>
+					<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.bold }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.boldDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_bold"/>
-				<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.bold }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.boldDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_bold"/>
+					<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.small }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.smallDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_small"/>
-				<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.small }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.smallDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_small"/>
+					<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.quote }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.quoteDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_quote"/>
-				<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.quote }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.quoteDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_quote"/>
+					<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.center }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.centerDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_center"/>
-				<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.center }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.centerDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_center"/>
+					<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.inlineCode }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.inlineCodeDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_inlineCode"/>
-				<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.inlineCode }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.inlineCodeDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_inlineCode"/>
+					<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.blockCode }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.blockCodeDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_blockCode"/>
-				<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.blockCode }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.blockCodeDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_blockCode"/>
+					<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.inlineMath }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.inlineMathDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_inlineMath"/>
-				<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.inlineMath }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.inlineMathDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_inlineMath"/>
+					<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<!-- deprecated
+		<!-- deprecated
 	<div class="section _block">
 		<div class="title">{{ $ts._mfm.search }}</div>
 		<div class="content">
@@ -133,216 +135,210 @@
 		</div>
 	</div>
 	-->
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.flip }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.flipDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_flip"/>
-				<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.flip }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.flipDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_flip"/>
+					<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.font }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.fontDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_font"/>
-				<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.font }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.fontDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_font"/>
+					<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.x2 }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.x2Description }}</p>
-			<div class="preview">
-				<Mfm :text="preview_x2"/>
-				<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.x2 }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.x2Description }}</p>
+				<div class="preview">
+					<Mfm :text="preview_x2"/>
+					<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.x3 }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.x3Description }}</p>
-			<div class="preview">
-				<Mfm :text="preview_x3"/>
-				<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.x3 }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.x3Description }}</p>
+				<div class="preview">
+					<Mfm :text="preview_x3"/>
+					<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.x4 }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.x4Description }}</p>
-			<div class="preview">
-				<Mfm :text="preview_x4"/>
-				<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.x4 }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.x4Description }}</p>
+				<div class="preview">
+					<Mfm :text="preview_x4"/>
+					<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.blur }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.blurDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_blur"/>
-				<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.blur }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.blurDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_blur"/>
+					<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.jelly }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.jellyDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_jelly"/>
-				<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.jelly }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.jellyDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_jelly"/>
+					<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.tada }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.tadaDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_tada"/>
-				<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.tada }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.tadaDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_tada"/>
+					<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.jump }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.jumpDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_jump"/>
-				<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.jump }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.jumpDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_jump"/>
+					<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.bounce }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.bounceDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_bounce"/>
-				<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.bounce }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.bounceDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_bounce"/>
+					<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.spin }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.spinDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_spin"/>
-				<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.spin }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.spinDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_spin"/>
+					<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.shake }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.shakeDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_shake"/>
-				<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.shake }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.shakeDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_shake"/>
+					<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.twitch }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.twitchDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_twitch"/>
-				<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.twitch }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.twitchDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_twitch"/>
+					<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.rainbow }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.rainbowDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_rainbow"/>
-				<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.rainbow }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.rainbowDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_rainbow"/>
+					<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.sparkle }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.sparkleDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_sparkle"/>
-				<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.sparkle }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.sparkleDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_sparkle"/>
+					<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+				</div>
 			</div>
 		</div>
-	</div>
-	<div class="section _block">
-		<div class="title">{{ $ts._mfm.rotate }}</div>
-		<div class="content">
-			<p>{{ $ts._mfm.rotateDescription }}</p>
-			<div class="preview">
-				<Mfm :text="preview_rotate"/>
-				<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
+		<div class="section _block">
+			<div class="title">{{ $ts._mfm.rotate }}</div>
+			<div class="content">
+				<p>{{ $ts._mfm.rotateDescription }}</p>
+				<div class="preview">
+					<Mfm :text="preview_rotate"/>
+					<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
+				</div>
 			</div>
 		</div>
 	</div>
-</div>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
+<script lang="ts" setup>
 import { defineComponent } from 'vue';
 import MkTextarea from '@/components/form/textarea.vue';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
 
-export default defineComponent({
-	components: {
-		MkTextarea
-	},
+const preview_mention = '@example';
+const preview_hashtag = '#test';
+const preview_url = 'https://example.com';
+const preview_link = `[${i18n.ts._mfm.dummy}](https://example.com)`;
+const preview_emoji = instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:';
+const preview_bold = `**${i18n.ts._mfm.dummy}**`;
+const preview_small = `<small>${i18n.ts._mfm.dummy}</small>`;
+const preview_center = `<center>${i18n.ts._mfm.dummy}</center>`;
+const preview_inlineCode = '`<: "Hello, world!"`';
+const preview_blockCode = '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```';
+const preview_inlineMath = '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)';
+const preview_quote = `> ${i18n.ts._mfm.dummy}`;
+const preview_search = `${i18n.ts._mfm.dummy} 検索`;
+const preview_jelly = '$[jelly 🍮] $[jelly.speed=5s 🍮]';
+const preview_tada = '$[tada 🍮] $[tada.speed=5s 🍮]';
+const preview_jump = '$[jump 🍮] $[jump.speed=5s 🍮]';
+const preview_bounce = '$[bounce 🍮] $[bounce.speed=5s 🍮]';
+const preview_shake = '$[shake 🍮] $[shake.speed=5s 🍮]';
+const preview_twitch = '$[twitch 🍮] $[twitch.speed=5s 🍮]';
+const preview_spin = '$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]';
+const preview_flip = `$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`;
+const preview_font = `$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`;
+const preview_x2 = '$[x2 🍮]';
+const preview_x3 = '$[x3 🍮]';
+const preview_x4 = '$[x4 🍮]';
+const preview_blur = `$[blur ${i18n.ts._mfm.dummy}]`;
+const preview_rainbow = '$[rainbow 🍮] $[rainbow.speed=5s 🍮]';
+const preview_sparkle = '$[sparkle 🍮]';
+const preview_rotate = '$[rotate 🍮]';
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts._mfm.cheatSheet,
-				icon: 'fas fa-question-circle',
-			},
-			preview_mention: '@example',
-			preview_hashtag: '#test',
-			preview_url: `https://example.com`,
-			preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`,
-			preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`,
-			preview_bold: `**${this.$ts._mfm.dummy}**`,
-			preview_small: `<small>${this.$ts._mfm.dummy}</small>`,
-			preview_center: `<center>${this.$ts._mfm.dummy}</center>`,
-			preview_inlineCode: '`<: "Hello, world!"`',
-			preview_blockCode: '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```',
-			preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)',
-			preview_quote: `> ${this.$ts._mfm.dummy}`,
-			preview_search: `${this.$ts._mfm.dummy} 検索`,
-			preview_jelly: `$[jelly 🍮] $[jelly.speed=5s 🍮]`,
-			preview_tada: `$[tada 🍮] $[tada.speed=5s 🍮]`,
-			preview_jump: `$[jump 🍮] $[jump.speed=5s 🍮]`,
-			preview_bounce: `$[bounce 🍮] $[bounce.speed=5s 🍮]`,
-			preview_shake: `$[shake 🍮] $[shake.speed=5s 🍮]`,
-			preview_twitch: `$[twitch 🍮] $[twitch.speed=5s 🍮]`,
-			preview_spin: `$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]`,
-			preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`,
-			preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`,
-			preview_x2: `$[x2 🍮]`,
-			preview_x3: `$[x3 🍮]`,
-			preview_x4: `$[x4 🍮]`,
-			preview_blur: `$[blur ${this.$ts._mfm.dummy}]`,
-			preview_rainbow: `$[rainbow 🍮] $[rainbow.speed=5s 🍮]`,
-			preview_sparkle: `$[sparkle 🍮]`,
-			preview_rotate: `$[rotate 🍮]`,
-		};
-	},
+definePageMetadata({
+	title: i18n.ts._mfm.cheatSheet,
+	icon: 'fas fa-question-circle',
 });
 </script>
 
diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue
index 4032d7723e..4b3ac7761e 100644
--- a/packages/client/src/pages/miauth.vue
+++ b/packages/client/src/pages/miauth.vue
@@ -49,28 +49,12 @@ export default defineComponent({
 		MkSignin,
 		MkButton,
 	},
+	props: ['session', 'callback', 'name', 'icon', 'permission'],
 	data() {
 		return {
-			state: null
+			state: null,
 		};
 	},
-	computed: {
-		session(): string {
-			return this.$route.params.session;
-		},
-		callback(): string {
-			return this.$route.query.callback;
-		},
-		name(): string {
-			return this.$route.query.name;
-		},
-		icon(): string {
-			return this.$route.query.icon;
-		},
-		permission(): string[] {
-			return this.$route.query.permission ? this.$route.query.permission.split(',') : [];
-		},
-	},
 	methods: {
 		async accept() {
 			this.state = 'waiting';
@@ -84,7 +68,7 @@ export default defineComponent({
 			this.state = 'accepted';
 			if (this.callback) {
 				location.href = appendQuery(this.callback, query({
-					session: this.session
+					session: this.session,
 				}));
 			}
 		},
@@ -93,8 +77,8 @@ export default defineComponent({
 		},
 		onLogin(res) {
 			login(res.i);
-		}
-	}
+		},
+	},
 });
 </script>
 
diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue
index a08bece731..e792834a89 100644
--- a/packages/client/src/pages/my-antennas/create.vue
+++ b/packages/client/src/pages/my-antennas/create.vue
@@ -5,11 +5,13 @@
 </template>
 
 <script lang="ts" setup>
-import { } from 'vue';
+import { inject } from 'vue';
 import XAntenna from './editor.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { useRouter } from '@/router';
+
+const router = useRouter();
 
 let draft = $ref({
 	name: '',
@@ -22,19 +24,21 @@ let draft = $ref({
 	withReplies: false,
 	caseSensitive: false,
 	withFile: false,
-	notify: false
+	notify: false,
 });
 
 function onAntennaCreated() {
 	router.push('/my/antennas');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageAntennas,
-		icon: 'fas fa-satellite',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'fas fa-satellite',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue
index 38e56ce35d..53f9b07db0 100644
--- a/packages/client/src/pages/my-antennas/edit.vue
+++ b/packages/client/src/pages/my-antennas/edit.vue
@@ -5,14 +5,14 @@
 </template>
 
 <script lang="ts" setup>
-import { watch } from 'vue';
+import { inject, watch } from 'vue';
 import XAntenna from './editor.vue';
-import * as symbols from '@/symbols';
 import * as os from '@/os';
-import { MisskeyNavigator } from '@/scripts/navigate';
 import { i18n } from '@/i18n';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-const nav = new MisskeyNavigator();
+const router = useRouter();
 
 let antenna: any = $ref(null);
 
@@ -21,18 +21,20 @@ const props = defineProps<{
 }>();
 
 function onAntennaUpdated() {
-	nav.push('/my/antennas');
+	router.push('/my/antennas');
 }
 
 os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => {
 	antenna = antennaResponse;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageAntennas,
-		icon: 'fas fa-satellite',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'fas fa-satellite',
 });
 </script>
 
diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue
index a568f64c52..2cdb26031f 100644
--- a/packages/client/src/pages/my-antennas/index.vue
+++ b/packages/client/src/pages/my-antennas/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="ieepwinx">
 		<MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ i18n.ts.add }}</MkButton>
 
@@ -11,27 +12,29 @@
 			</MkPagination>
 		</div>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'antennas/list' as const,
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageAntennas,
-		icon: 'fas fa-satellite',
-		bg: 'var(--bg)'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageAntennas,
+	icon: 'fas fa-satellite',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue
index e287357a42..6434b0c004 100644
--- a/packages/client/src/pages/my-clips/index.vue
+++ b/packages/client/src/pages/my-clips/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="qtcaoidl">
 		<MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
 
@@ -10,7 +11,7 @@
 			</MkA>
 		</MkPagination>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -18,8 +19,8 @@ import { } from 'vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'clips/list' as const,
@@ -61,15 +62,17 @@ function onClipDeleted() {
 	pagingComponent.reload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.clip,
-		icon: 'fas fa-paperclip',
-		bg: 'var(--bg)',
-		action: {
-			icon: 'fas fa-plus',
-			handler: create
-		},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.clip,
+	icon: 'fas fa-paperclip',
+	bg: 'var(--bg)',
+	action: {
+		icon: 'fas fa-plus',
+		handler: create,
 	},
 });
 </script>
diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue
deleted file mode 100644
index 92c0483af9..0000000000
--- a/packages/client/src/pages/my-groups/group.vue
+++ /dev/null
@@ -1,178 +0,0 @@
-<template>
-<div class="mk-group-page">
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<div v-if="group" class="_section">
-			<div class="_content" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
-				<MkButton inline @click="invite()">{{ $ts.invite }}</MkButton>
-				<MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton>
-				<MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton>
-				<MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton>
-			</div>
-		</div>
-	</transition>
-
-	<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
-		<div v-if="group" class="_section members _gap">
-			<div class="_title">{{ $ts.members }}</div>
-			<div class="_content">
-				<div class="users">
-					<div v-for="user in users" :key="user.id" class="user _panel">
-						<MkAvatar :user="user" class="avatar" :show-indicator="true"/>
-						<div class="body">
-							<MkUserName :user="user" class="name"/>
-							<MkAcct :user="user" class="acct"/>
-						</div>
-						<div class="action">
-							<button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button>
-						</div>
-					</div>
-				</div>
-			</div>
-		</div>
-	</transition>
-</div>
-</template>
-
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkButton
-	},
-
-	props: {
-		groupId: {
-			type: String,
-			required: true,
-		},
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.group ? {
-				title: this.group.name,
-				icon: 'fas fa-users',
-			} : null),
-			group: null,
-			users: [],
-		};
-	},
-
-	watch: {
-		groupId: 'fetch',
-	},
-
-	created() {
-		this.fetch();
-	},
-
-	methods: {
-		fetch() {
-			os.api('users/groups/show', {
-				groupId: this.groupId
-			}).then(group => {
-				this.group = group;
-				os.api('users/show', {
-					userIds: this.group.userIds
-				}).then(users => {
-					this.users = users;
-				});
-			});
-		},
-
-		invite() {
-			os.selectUser().then(user => {
-				os.apiWithDialog('users/groups/invite', {
-					groupId: this.group.id,
-					userId: user.id
-				});
-			});
-		},
-
-		removeUser(user) {
-			os.api('users/groups/pull', {
-				groupId: this.group.id,
-				userId: user.id
-			}).then(() => {
-				this.users = this.users.filter(x => x.id !== user.id);
-			});
-		},
-
-		async renameGroup() {
-			const { canceled, result: name } = await os.inputText({
-				title: this.$ts.groupName,
-				default: this.group.name
-			});
-			if (canceled) return;
-
-			await os.api('users/groups/update', {
-				groupId: this.group.id,
-				name: name
-			});
-
-			this.group.name = name;
-		},
-
-		transfer() {
-			os.selectUser().then(user => {
-				os.apiWithDialog('users/groups/transfer', {
-					groupId: this.group.id,
-					userId: user.id
-				});
-			});
-		},
-
-		async deleteGroup() {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$t('removeAreYouSure', { x: this.group.name }),
-			});
-			if (canceled) return;
-
-			await os.apiWithDialog('users/groups/delete', {
-				groupId: this.group.id
-			});
-			this.$router.push('/my/groups');
-		}
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-group-page {
-	> .members {
-		> ._content {
-			> .users {
-				> .user {
-					display: flex;
-					align-items: center;
-					padding: 16px;
-
-					> .avatar {
-						width: 50px;
-						height: 50px;
-					}
-
-					> .body {
-						flex: 1;
-						padding: 8px;
-
-						> .name {
-							display: block;
-							font-weight: bold;
-						}
-
-						> .acct {
-							opacity: 0.5;
-						}
-					}
-				}
-			}
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue
deleted file mode 100644
index 4b2b2963a8..0000000000
--- a/packages/client/src/pages/my-groups/index.vue
+++ /dev/null
@@ -1,147 +0,0 @@
-<template>
-<MkSpacer :content-max="700">
-	<div v-if="tab === 'owned'" class="_content">
-		<MkButton primary style="margin: 0 auto var(--margin) auto;" @click="create"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton>
-
-		<MkPagination v-slot="{items}" ref="owned" :pagination="ownedPagination">
-			<div v-for="group in items" :key="group.id" class="_card">
-				<div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div>
-				<div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
-			</div>
-		</MkPagination>
-	</div>
-
-	<div v-else-if="tab === 'joined'" class="_content">
-		<MkPagination v-slot="{items}" ref="joined" :pagination="joinedPagination">
-			<div v-for="group in items" :key="group.id" class="_card">
-				<div class="_title">{{ group.name }}</div>
-				<div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
-				<div class="_footer">
-					<MkButton danger @click="leave(group)">{{ $ts.leaveGroup }}</MkButton>
-				</div>
-			</div>
-		</MkPagination>
-	</div>
-
-	<div v-else-if="tab === 'invites'" class="_content">
-		<MkPagination v-slot="{items}" ref="invitations" :pagination="invitationPagination">
-			<div v-for="invitation in items" :key="invitation.id" class="_card">
-				<div class="_title">{{ invitation.group.name }}</div>
-				<div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div>
-				<div class="_footer">
-					<MkButton primary inline @click="acceptInvite(invitation)"><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton>
-					<MkButton primary inline @click="rejectInvite(invitation)"><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton>
-				</div>
-			</div>
-		</MkPagination>
-	</div>
-</MkSpacer>
-</template>
-
-<script lang="ts">
-import { defineComponent, computed } from 'vue';
-import MkPagination from '@/components/ui/pagination.vue';
-import MkButton from '@/components/ui/button.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkAvatars from '@/components/avatars.vue';
-import MkTab from '@/components/tab.vue';
-import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkPagination,
-		MkButton,
-		MkContainer,
-		MkTab,
-		MkAvatars,
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.groups,
-				icon: 'fas fa-users',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-plus',
-					text: this.$ts.createGroup,
-					handler: this.create,
-				}],
-				tabs: [{
-					active: this.tab === 'owned',
-					title: this.$ts.ownedGroups,
-					icon: 'fas fa-user-tie',
-					onClick: () => { this.tab = 'owned'; },
-				}, {
-					active: this.tab === 'joined',
-					title: this.$ts.joinedGroups,
-					icon: 'fas fa-id-badge',
-					onClick: () => { this.tab = 'joined'; },
-				}, {
-					active: this.tab === 'invites',
-					title: this.$ts.invites,
-					icon: 'fas fa-envelope-open-text',
-					onClick: () => { this.tab = 'invites'; },
-				},]
-			})),
-			tab: 'owned',
-			ownedPagination: {
-				endpoint: 'users/groups/owned' as const,
-				limit: 10,
-			},
-			joinedPagination: {
-				endpoint: 'users/groups/joined' as const,
-				limit: 10,
-			},
-			invitationPagination: {
-				endpoint: 'i/user-group-invites' as const,
-				limit: 10,
-			},
-		};
-	},
-
-	methods: {
-		async create() {
-			const { canceled, result: name } = await os.inputText({
-				title: this.$ts.groupName,
-			});
-			if (canceled) return;
-			await os.api('users/groups/create', { name: name });
-			this.$refs.owned.reload();
-			os.success();
-		},
-		acceptInvite(invitation) {
-			os.api('users/groups/invitations/accept', {
-				invitationId: invitation.id
-			}).then(() => {
-				os.success();
-				this.$refs.invitations.reload();
-				this.$refs.joined.reload();
-			});
-		},
-		rejectInvite(invitation) {
-			os.api('users/groups/invitations/reject', {
-				invitationId: invitation.id
-			}).then(() => {
-				this.$refs.invitations.reload();
-			});
-		},
-		async leave(group) {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$t('leaveGroupConfirm', { name: group.name }),
-			});
-			if (canceled) return;
-			os.apiWithDialog('users/groups/leave', {
-				groupId: group.id,
-			}).then(() => {
-				this.$refs.joined.reload();
-			});
-		}
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-</style>
diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue
index 9ed9e2960e..411826a950 100644
--- a/packages/client/src/pages/my-lists/index.vue
+++ b/packages/client/src/pages/my-lists/index.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="qkcjvfiv">
 		<MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton>
 
@@ -10,7 +11,7 @@
 			</MkA>
 		</MkPagination>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -19,8 +20,8 @@ import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
 import MkAvatars from '@/components/avatars.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagingComponent = $ref<InstanceType<typeof MkPagination>>();
 
@@ -38,15 +39,17 @@ async function create() {
 	pagingComponent.reload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.manageLists,
-		icon: 'fas fa-list-ul',
-		bg: 'var(--bg)',
-		action: {
-			icon: 'fas fa-plus',
-			handler: create,
-		},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.manageLists,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
+	action: {
+		icon: 'fas fa-plus',
+		handler: create,
 	},
 });
 </script>
diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue
index bc24f58431..6e76c4a7d5 100644
--- a/packages/client/src/pages/my-lists/list.vue
+++ b/packages/client/src/pages/my-lists/list.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="mk-list-page">
 		<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
 			<div v-if="list" class="_section">
@@ -31,104 +32,96 @@
 			</div>
 		</transition>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		MkButton
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.list ? {
-				title: this.list.name,
-				icon: 'fas fa-list-ul',
-				bg: 'var(--bg)',
-			} : null),
-			list: null,
-			users: [],
-		};
-	},
-
-	watch: {
-		$route: 'fetch'
-	},
-
-	created() {
-		this.fetch();
-	},
-
-	methods: {
-		fetch() {
-			os.api('users/lists/show', {
-				listId: this.$route.params.list
-			}).then(list => {
-				this.list = list;
-				os.api('users/show', {
-					userIds: this.list.userIds
-				}).then(users => {
-					this.users = users;
-				});
-			});
-		},
-
-		addUser() {
-			os.selectUser().then(user => {
-				os.apiWithDialog('users/lists/push', {
-					listId: this.list.id,
-					userId: user.id
-				}).then(() => {
-					this.users.push(user);
-				});
-			});
-		},
-
-		removeUser(user) {
-			os.api('users/lists/pull', {
-				listId: this.list.id,
-				userId: user.id
-			}).then(() => {
-				this.users = this.users.filter(x => x.id !== user.id);
-			});
-		},
-
-		async renameList() {
-			const { canceled, result: name } = await os.inputText({
-				title: this.$ts.enterListName,
-				default: this.list.name
-			});
-			if (canceled) return;
-
-			await os.api('users/lists/update', {
-				listId: this.list.id,
-				name: name
-			});
-
-			this.list.name = name;
-		},
-
-		async deleteList() {
-			const { canceled } = await os.confirm({
-				type: 'warning',
-				text: this.$t('removeAreYouSure', { x: this.list.name }),
-			});
-			if (canceled) return;
-
-			await os.api('users/lists/delete', {
-				listId: this.list.id
-			});
-			os.success();
-			this.$router.push('/my/lists');
-		}
-	}
-});
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	listId: string;
+}>();
+
+let list = $ref(null);
+let users = $ref([]);
+
+function fetchList() {
+	os.api('users/lists/show', {
+		listId: props.listId,
+	}).then(_list => {
+		list = _list;
+		os.api('users/show', {
+			userIds: list.userIds,
+		}).then(_users => {
+			users = _users;
+		});
+	});
+}
+
+function addUser() {
+	os.selectUser().then(user => {
+		os.apiWithDialog('users/lists/push', {
+			listId: list.id,
+			userId: user.id,
+		}).then(() => {
+			users.push(user);
+		});
+	});
+}
+
+function removeUser(user) {
+	os.api('users/lists/pull', {
+		listId: list.id,
+		userId: user.id,
+	}).then(() => {
+		users = users.filter(x => x.id !== user.id);
+	});
+}
+
+async function renameList() {
+	const { canceled, result: name } = await os.inputText({
+		title: i18n.ts.enterListName,
+		default: list.name,
+	});
+	if (canceled) return;
+
+	await os.api('users/lists/update', {
+		listId: list.id,
+		name: name,
+	});
+
+	list.name = name;
+}
+
+async function deleteList() {
+	const { canceled } = await os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: list.name }),
+	});
+	if (canceled) return;
+
+	await os.api('users/lists/delete', {
+		listId: list.id,
+	});
+	os.success();
+	mainRouter.push('/my/lists');
+}
+
+watch(() => props.listId, fetchList, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+	title: list.name,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue
index cdeb54b88b..955fbbccfd 100644
--- a/packages/client/src/pages/not-found.vue
+++ b/packages/client/src/pages/not-found.vue
@@ -8,14 +8,16 @@
 </template>
 
 <script lang="ts" setup>
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.notFound,
-		icon: 'fas fa-exclamation-triangle',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.notFound,
+	icon: 'fas fa-exclamation-triangle',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue
index f0a18ecc36..852b821573 100644
--- a/packages/client/src/pages/note.vue
+++ b/packages/client/src/pages/note.vue
@@ -1,10 +1,11 @@
-<template>
-<MkSpacer :content-max="800">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800">
 	<div class="fcuexfpr">
 		<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
 			<div v-if="note" class="note">
 				<div v-if="showNext" class="_gap">
-					<XNotes class="_content" :pagination="next" :no-gap="true"/>
+					<XNotes class="_content" :pagination="nextPagination" :no-gap="true"/>
 				</div>
 
 				<div class="main _gap">
@@ -27,121 +28,112 @@
 				</div>
 
 				<div v-if="showPrev" class="_gap">
-					<XNotes class="_content" :pagination="prev" :no-gap="true"/>
+					<XNotes class="_content" :pagination="prevPagination" :no-gap="true"/>
 				</div>
 			</div>
 			<MkError v-else-if="error" @retry="fetch()"/>
 			<MkLoading v-else/>
 		</transition>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineComponent, watch } from 'vue';
+import * as misskey from 'misskey-js';
 import XNote from '@/components/note.vue';
 import XNoteDetailed from '@/components/note-detailed.vue';
 import XNotes from '@/components/notes.vue';
 import MkRemoteCaution from '@/components/remote-caution.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		XNote,
-		XNoteDetailed,
-		XNotes,
-		MkRemoteCaution,
-		MkButton,
-	},
-	props: {
-		noteId: {
-			type: String,
-			required: true
-		}
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.note ? {
-				title: this.$ts.note,
-				subtitle: new Date(this.note.createdAt).toLocaleString(),
-				avatar: this.note.user,
-				path: `/notes/${this.note.id}`,
-				share: {
-					title: this.$t('noteOf', { user: this.note.user.name }),
-					text: this.note.text,
-				},
-				bg: 'var(--bg)',
-			} : null),
-			note: null,
-			clips: null,
-			hasPrev: false,
-			hasNext: false,
-			showPrev: false,
-			showNext: false,
-			error: null,
-			prev: {
-				endpoint: 'users/notes' as const,
-				limit: 10,
-				params: computed(() => ({
-					userId: this.note.userId,
-					untilId: this.note.id,
-				})),
-			},
-			next: {
-				reversed: true,
-				endpoint: 'users/notes' as const,
-				limit: 10,
-				params: computed(() => ({
-					userId: this.note.userId,
-					sinceId: this.note.id,
-				})),
-			},
-		};
-	},
-	watch: {
-		noteId: 'fetch'
-	},
-	created() {
-		this.fetch();
-	},
-	methods: {
-		fetch() {
-			this.hasPrev = false;
-			this.hasNext = false;
-			this.showPrev = false;
-			this.showNext = false;
-			this.note = null;
-			os.api('notes/show', {
-				noteId: this.noteId
-			}).then(note => {
-				this.note = note;
-				Promise.all([
-					os.api('notes/clips', {
-						noteId: note.id,
-					}),
-					os.api('users/notes', {
-						userId: note.userId,
-						untilId: note.id,
-						limit: 1,
-					}),
-					os.api('users/notes', {
-						userId: note.userId,
-						sinceId: note.id,
-						limit: 1,
-					}),
-				]).then(([clips, prev, next]) => {
-					this.clips = clips;
-					this.hasPrev = prev.length !== 0;
-					this.hasNext = next.length !== 0;
-				});
-			}).catch(err => {
-				this.error = err;
-			});
-		}
-	}
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const props = defineProps<{
+	noteId: string;
+}>();
+
+let note = $ref<null | misskey.entities.Note>();
+let clips = $ref();
+let hasPrev = $ref(false);
+let hasNext = $ref(false);
+let showPrev = $ref(false);
+let showNext = $ref(false);
+let error = $ref();
+
+const prevPagination = {
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => note ? ({
+		userId: note.userId,
+		untilId: note.id,
+	}) : null),
+};
+
+const nextPagination = {
+	reversed: true,
+	endpoint: 'users/notes' as const,
+	limit: 10,
+	params: computed(() => note ? ({
+		userId: note.userId,
+		sinceId: note.id,
+	}) : null),
+};
+
+function fetchNote() {
+	hasPrev = false;
+	hasNext = false;
+	showPrev = false;
+	showNext = false;
+	note = null;
+	os.api('notes/show', {
+		noteId: props.noteId,
+	}).then(res => {
+		note = res;
+		Promise.all([
+			os.api('notes/clips', {
+				noteId: note.id,
+			}),
+			os.api('users/notes', {
+				userId: note.userId,
+				untilId: note.id,
+				limit: 1,
+			}),
+			os.api('users/notes', {
+				userId: note.userId,
+				sinceId: note.id,
+				limit: 1,
+			}),
+		]).then(([_clips, prev, next]) => {
+			clips = _clips;
+			hasPrev = prev.length !== 0;
+			hasNext = next.length !== 0;
+		});
+	}).catch(err => {
+		error = err;
+	});
+}
+
+watch(() => props.noteId, fetchNote, {
+	immediate: true,
 });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => note ? {
+	title: i18n.ts.note,
+	subtitle: new Date(note.createdAt).toLocaleString(),
+	avatar: note.user,
+	path: `/notes/${note.id}`,
+	share: {
+		title: i18n.t('noteOf', { user: note.user.name }),
+		text: note.text,
+	},
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue
index 36e423e534..3d1014b3cd 100644
--- a/packages/client/src/pages/notifications.vue
+++ b/packages/client/src/pages/notifications.vue
@@ -1,18 +1,21 @@
 <template>
-<MkSpacer :content-max="800">
-	<div class="clupoqwt">
-		<XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
-	</div>
-</MkSpacer>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div class="clupoqwt">
+			<XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
+import { notificationTypes } from 'misskey-js';
 import XNotifications from '@/components/notifications.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { notificationTypes } from 'misskey-js';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let tab = $ref('all');
 let includeTypes = $ref<string[] | null>(null);
@@ -23,46 +26,46 @@ function setFilter(ev) {
 		active: includeTypes && includeTypes.includes(t),
 		action: () => {
 			includeTypes = [t];
-		}
+		},
 	}));
 	const items = includeTypes != null ? [{
 		icon: 'fas fa-times',
 		text: i18n.ts.clear,
 		action: () => {
 			includeTypes = null;
-		}
+		},
 	}, null, ...typeItems] : typeItems;
 	os.popupMenu(items, ev.currentTarget ?? ev.target);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.notifications,
-		icon: 'fas fa-bell',
-		bg: 'var(--bg)',
-		actions: [{
-			text: i18n.ts.filter,
-			icon: 'fas fa-filter',
-			highlighted: includeTypes != null,
-			handler: setFilter,
-		}, {
-			text: i18n.ts.markAllAsRead,
-			icon: 'fas fa-check',
-			handler: () => {
-				os.apiWithDialog('notifications/mark-all-as-read');
-			},
-		}],
-		tabs: [{
-			active: tab === 'all',
-			title: i18n.ts.all,
-			onClick: () => { tab = 'all'; },
-		}, {
-			active: tab === 'unread',
-			title: i18n.ts.unread,
-			onClick: () => { tab = 'unread'; },
-		},]
-	})),
-});
+const headerActions = $computed(() => [{
+	text: i18n.ts.filter,
+	icon: 'fas fa-filter',
+	highlighted: includeTypes != null,
+	handler: setFilter,
+}, {
+	text: i18n.ts.markAllAsRead,
+	icon: 'fas fa-check',
+	handler: () => {
+		os.apiWithDialog('notifications/mark-all-as-read');
+	},
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'all',
+	title: i18n.ts.all,
+	onClick: () => { tab = 'all'; },
+}, {
+	active: tab === 'unread',
+	title: i18n.ts.unread,
+	onClick: () => { tab = 'unread'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.notifications,
+	icon: 'fas fa-bell',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue
index 9566592618..97c278e5e8 100644
--- a/packages/client/src/pages/page-editor/page-editor.vue
+++ b/packages/client/src/pages/page-editor/page-editor.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<div class="jqqmcavi">
 		<MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton>
 		<MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
@@ -55,7 +56,7 @@
 			<XDraggable v-show="variables.length > 0" v-model="variables" tag="div" class="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
 				<template #item="{element}">
 					<XVariable
-						:modelValue="element"
+						:model-value="element"
 						:removable="true"
 						:hpml="hpml"
 						:name="element.name"
@@ -75,11 +76,11 @@
 			<MkTextarea v-model="script" class="_code"/>
 		</div>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { defineComponent, defineAsyncComponent, computed, provide, watch } from 'vue';
 import 'prismjs';
 import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
@@ -101,367 +102,349 @@ import { url } from '@/config';
 import { collectPageVars } from '@/scripts/collect-page-vars';
 import * as os from '@/os';
 import { selectFile } from '@/scripts/select-file';
-import * as symbols from '@/symbols';
-
-export default defineComponent({
-	components: {
-		XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
-		XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput,
-	},
-
-	provide() {
-		return {
-			readonly: this.readonly,
-			getScriptBlockList: this.getScriptBlockList,
-			getPageBlockList: this.getPageBlockList
-		};
-	},
-
-	props: {
-		initPageId: {
-			type: String,
-			required: false
-		},
-		initPageName: {
-			type: String,
-			required: false
-		},
-		initUser: {
-			type: String,
-			required: false
-		},
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => {
-				let title = this.$ts._pages.newPage;
-				if (this.initPageId) {
-					title = this.$ts._pages.editPage;
-				}
-				else if (this.initPageName && this.initUser) {
-					title = this.$ts._pages.readPage;
-				}
-				return {
-					title: title,
-					icon: 'fas fa-pencil-alt',
-					bg: 'var(--bg)',
-					tabs: [{
-						active: this.tab === 'settings',
-						title: this.$ts._pages.pageSetting,
-						icon: 'fas fa-cog',
-						onClick: () => { this.tab = 'settings'; },
-					}, {
-						active: this.tab === 'contents',
-						title: this.$ts._pages.contents,
-						icon: 'fas fa-sticky-note',
-						onClick: () => { this.tab = 'contents'; },
-					}, {
-						active: this.tab === 'variables',
-						title: this.$ts._pages.variables,
-						icon: 'fas fa-magic',
-						onClick: () => { this.tab = 'variables'; },
-					}, {
-						active: this.tab === 'script',
-						title: this.$ts.script,
-						icon: 'fas fa-code',
-						onClick: () => { this.tab = 'script'; },
-					}],
-				};
-			}),
-			tab: 'settings',
-			author: this.$i,
-			readonly: false,
-			page: null,
-			pageId: null,
-			currentName: null,
-			title: '',
-			summary: null,
-			name: Date.now().toString(),
-			eyeCatchingImage: null,
-			eyeCatchingImageId: null,
-			font: 'sans-serif',
-			content: [],
-			alignCenter: false,
-			hideTitleWhenPinned: false,
-			variables: [],
-			hpml: null,
-			script: '',
-			url,
-		};
-	},
-
-	watch: {
-		async eyeCatchingImageId() {
-			if (this.eyeCatchingImageId == null) {
-				this.eyeCatchingImage = null;
-			} else {
-				this.eyeCatchingImage = await os.api('drive/files/show', {
-					fileId: this.eyeCatchingImageId,
-				});
-			}
-		},
-	},
-
-	async created() {
-		this.hpml = new HpmlTypeChecker();
+import { mainRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { $i } from '@/account';
+const XDraggable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
+
+const props = defineProps<{
+	initPageId?: string;
+	initPageName?: string;
+	initUser?: string;
+}>();
+
+let tab = $ref('settings');
+let author = $ref($i);
+let readonly = $ref(false);
+let page = $ref(null);
+let pageId = $ref(null);
+let currentName = $ref(null);
+let title = $ref('');
+let summary = $ref(null);
+let name = $ref(Date.now().toString());
+let eyeCatchingImage = $ref(null);
+let eyeCatchingImageId = $ref(null);
+let font = $ref('sans-serif');
+let content = $ref([]);
+let alignCenter = $ref(false);
+let hideTitleWhenPinned = $ref(false);
+let variables = $ref([]);
+let hpml = $ref(null);
+let script = $ref('');
+
+provide('readonly', readonly);
+provide('getScriptBlockList', getScriptBlockList);
+provide('getPageBlockList', getPageBlockList);
+
+watch($$(eyeCatchingImageId), async () => {
+	if (eyeCatchingImageId == null) {
+		eyeCatchingImage = null;
+	} else {
+		eyeCatchingImage = await os.api('drive/files/show', {
+			fileId: eyeCatchingImageId,
+		});
+	}
+});
 
-		this.$watch('variables', () => {
-			this.hpml.variables = this.variables;
-		}, { deep: true });
+function getSaveOptions() {
+	return {
+		title: tatitle.trim(),
+		name: taname.trim(),
+		summary: tasummary,
+		font: tafont,
+		script: tascript,
+		hideTitleWhenPinned: tahideTitleWhenPinned,
+		alignCenter: taalignCenter,
+		content: tacontent,
+		variables: tavariables,
+		eyeCatchingImageId: taeyeCatchingImageId,
+	};
+}
 
-		this.$watch('content', () => {
-			this.hpml.pageVars = collectPageVars(this.content);
-		}, { deep: true });
+function save() {
+	const options = tagetSaveOptions();
 
-		if (this.initPageId) {
-			this.page = await os.api('pages/show', {
-				pageId: this.initPageId,
-			});
-		} else if (this.initPageName && this.initUser) {
-			this.page = await os.api('pages/show', {
-				name: this.initPageName,
-				username: this.initUser,
+	const onError = err => {
+		if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
+			if (err.info.param == 'name') {
+				os.alert({
+					type: 'error',
+					title: i18n.ts._pages.invalidNameTitle,
+					text: i18n.ts._pages.invalidNameText,
+				});
+			}
+		} else if (err.code == 'NAME_ALREADY_EXISTS') {
+			os.alert({
+				type: 'error',
+				text: i18n.ts._pages.nameAlreadyExists,
 			});
-			this.readonly = true;
 		}
-
-		if (this.page) {
-			this.author = this.page.user;
-			this.pageId = this.page.id;
-			this.title = this.page.title;
-			this.name = this.page.name;
-			this.currentName = this.page.name;
-			this.summary = this.page.summary;
-			this.font = this.page.font;
-			this.script = this.page.script;
-			this.hideTitleWhenPinned = this.page.hideTitleWhenPinned;
-			this.alignCenter = this.page.alignCenter;
-			this.content = this.page.content;
-			this.variables = this.page.variables;
-			this.eyeCatchingImageId = this.page.eyeCatchingImageId;
-		} else {
-			const id = uuid();
-			this.content = [{
-				id,
-				type: 'text',
-				text: 'Hello World!'
-			}];
-		}
-	},
-
-	methods: {
-		getSaveOptions() {
-			return {
-				title: this.title.trim(),
-				name: this.name.trim(),
-				summary: this.summary,
-				font: this.font,
-				script: this.script,
-				hideTitleWhenPinned: this.hideTitleWhenPinned,
-				alignCenter: this.alignCenter,
-				content: this.content,
-				variables: this.variables,
-				eyeCatchingImageId: this.eyeCatchingImageId,
-			};
-		},
-
-		save() {
-			const options = this.getSaveOptions();
-
-			const onError = err => {
-				if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
-					if (err.info.param == 'name') {
-						os.alert({
-							type: 'error',
-							title: this.$ts._pages.invalidNameTitle,
-							text: this.$ts._pages.invalidNameText
-						});
-					}
-				} else if (err.code == 'NAME_ALREADY_EXISTS') {
-					os.alert({
-						type: 'error',
-						text: this.$ts._pages.nameAlreadyExists
-					});
-				}
-			};
-
-			if (this.pageId) {
-				options.pageId = this.pageId;
-				os.api('pages/update', options)
-				.then(page => {
-					this.currentName = this.name.trim();
-					os.alert({
-						type: 'success',
-						text: this.$ts._pages.updated
-					});
-				}).catch(onError);
-			} else {
-				os.api('pages/create', options)
-				.then(page => {
-					this.pageId = page.id;
-					this.currentName = this.name.trim();
-					os.alert({
-						type: 'success',
-						text: this.$ts._pages.created
-					});
-					this.$router.push(`/pages/edit/${this.pageId}`);
-				}).catch(onError);
-			}
-		},
-
-		del() {
-			os.confirm({
-				type: 'warning',
-				text: this.$t('removeAreYouSure', { x: this.title.trim() }),
-			}).then(({ canceled }) => {
-				if (canceled) return;
-				os.api('pages/delete', {
-					pageId: this.pageId,
-				}).then(() => {
-					os.alert({
-						type: 'success',
-						text: this.$ts._pages.deleted
-					});
-					this.$router.push(`/pages`);
-				});
+	};
+
+	if (tapageId) {
+		options.pageId = tapageId;
+		os.api('pages/update', options)
+		.then(page => {
+			tacurrentName = taname.trim();
+			os.alert({
+				type: 'success',
+				text: i18n.ts._pages.updated,
 			});
-		},
-
-		duplicate() {
-			this.title = this.title + ' - copy';
-			this.name = this.name + '-copy';
-			os.api('pages/create', this.getSaveOptions()).then(page => {
-				this.pageId = page.id;
-				this.currentName = this.name.trim();
-				os.alert({
-					type: 'success',
-					text: this.$ts._pages.created
-				});
-				this.$router.push(`/pages/edit/${this.pageId}`);
+		}).catch(onError);
+	} else {
+		os.api('pages/create', options)
+		.then(created => {
+			tapageId = created.id;
+			tacurrentName = name.trim();
+			os.alert({
+				type: 'success',
+				text: i18n.ts._pages.created,
 			});
-		},
+			mainRouter.push(`/pages/edit/${pageId}`);
+		}).catch(onError);
+	}
+}
 
-		async add() {
-			const { canceled, result: type } = await os.select({
-				type: null,
-				title: this.$ts._pages.chooseBlock,
-				groupedItems: this.getPageBlockList()
+function del() {
+	os.confirm({
+		type: 'warning',
+		text: i18n.t('removeAreYouSure', { x: title.trim() }),
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		os.api('pages/delete', {
+			pageId: pageId,
+		}).then(() => {
+			os.alert({
+				type: 'success',
+				text: i18n.ts._pages.deleted,
 			});
-			if (canceled) return;
+			mainRouter.push('/pages');
+		});
+	});
+}
 
-			const id = uuid();
-			this.content.push({ id, type });
-		},
+function duplicate() {
+	tatitle = tatitle + ' - copy';
+	taname = taname + '-copy';
+	os.api('pages/create', tagetSaveOptions()).then(created => {
+		tapageId = created.id;
+		tacurrentName = taname.trim();
+		os.alert({
+			type: 'success',
+			text: i18n.ts._pages.created,
+		});
+		mainRouter.push(`/pages/edit/${pageId}`);
+	});
+}
 
-		async addVariable() {
-			let { canceled, result: name } = await os.inputText({
-				title: this.$ts._pages.enterVariableName,
-			});
-			if (canceled) return;
+async function add() {
+	const { canceled, result: type } = await os.select({
+		type: null,
+		title: i18n.ts._pages.chooseBlock,
+		groupedItems: tagetPageBlockList(),
+	});
+	if (canceled) return;
 
-			name = name.trim();
+	const id = uuid();
+	tacontent.push({ id, type });
+}
 
-			if (this.hpml.isUsedName(name)) {
-				os.alert({
-					type: 'error',
-					text: this.$ts._pages.variableNameIsAlreadyUsed
-				});
-				return;
-			}
+async function addVariable() {
+	let { canceled, result: name } = await os.inputText({
+		title: i18n.ts._pages.enterVariableName,
+	});
+	if (canceled) return;
 
-			const id = uuid();
-			this.variables.push({ id, name, type: null });
-		},
-
-		removeVariable(v) {
-			this.variables = this.variables.filter(x => x.name !== v.name);
-		},
-
-		getPageBlockList() {
-			return [{
-				label: this.$ts._pages.contentBlocks,
-				items: [
-					{ value: 'section', text: this.$ts._pages.blocks.section },
-					{ value: 'text', text: this.$ts._pages.blocks.text },
-					{ value: 'image', text: this.$ts._pages.blocks.image },
-					{ value: 'textarea', text: this.$ts._pages.blocks.textarea },
-					{ value: 'note', text: this.$ts._pages.blocks.note },
-					{ value: 'canvas', text: this.$ts._pages.blocks.canvas },
-				]
-			}, {
-				label: this.$ts._pages.inputBlocks,
-				items: [
-					{ value: 'button', text: this.$ts._pages.blocks.button },
-					{ value: 'radioButton', text: this.$ts._pages.blocks.radioButton },
-					{ value: 'textInput', text: this.$ts._pages.blocks.textInput },
-					{ value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput },
-					{ value: 'numberInput', text: this.$ts._pages.blocks.numberInput },
-					{ value: 'switch', text: this.$ts._pages.blocks.switch },
-					{ value: 'counter', text: this.$ts._pages.blocks.counter }
-				]
-			}, {
-				label: this.$ts._pages.specialBlocks,
-				items: [
-					{ value: 'if', text: this.$ts._pages.blocks.if },
-					{ value: 'post', text: this.$ts._pages.blocks.post }
-				]
-			}];
-		},
-
-		getScriptBlockList(type: string = null) {
-			const list = [];
-
-			const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
-
-			for (const block of blocks) {
-				const category = list.find(x => x.category === block.category);
-				if (category) {
-					category.items.push({
-						value: block.type,
-						text: this.$t(`_pages.script.blocks.${block.type}`)
-					});
-				} else {
-					list.push({
-						category: block.category,
-						label: this.$t(`_pages.script.categories.${block.category}`),
-						items: [{
-							value: block.type,
-							text: this.$t(`_pages.script.blocks.${block.type}`)
-						}]
-					});
-				}
-			}
+	name = name.trim();
 
-			const userFns = this.variables.filter(x => x.type === 'fn');
-			if (userFns.length > 0) {
-				list.unshift({
-					label: this.$t(`_pages.script.categories.fn`),
-					items: userFns.map(v => ({
-						value: 'fn:' + v.name,
-						text: v.name
-					}))
-				});
-			}
+	if (tahpml.isUsedName(name)) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts._pages.variableNameIsAlreadyUsed,
+		});
+		return;
+	}
+
+	const id = uuid();
+	tavariables.push({ id, name, type: null });
+}
+
+function removeVariable(v) {
+	tavariables = tavariables.filter(x => x.name !== v.name);
+}
+
+function getPageBlockList() {
+	return [{
+		label: i18n.ts._pages.contentBlocks,
+		items: [
+			{ value: 'section', text: i18n.ts._pages.blocks.section },
+			{ value: 'text', text: i18n.ts._pages.blocks.text },
+			{ value: 'image', text: i18n.ts._pages.blocks.image },
+			{ value: 'textarea', text: i18n.ts._pages.blocks.textarea },
+			{ value: 'note', text: i18n.ts._pages.blocks.note },
+			{ value: 'canvas', text: i18n.ts._pages.blocks.canvas },
+		],
+	}, {
+		label: i18n.ts._pages.inputBlocks,
+		items: [
+			{ value: 'button', text: i18n.ts._pages.blocks.button },
+			{ value: 'radioButton', text: i18n.ts._pages.blocks.radioButton },
+			{ value: 'textInput', text: i18n.ts._pages.blocks.textInput },
+			{ value: 'textareaInput', text: i18n.ts._pages.blocks.textareaInput },
+			{ value: 'numberInput', text: i18n.ts._pages.blocks.numberInput },
+			{ value: 'switch', text: i18n.ts._pages.blocks.switch },
+			{ value: 'counter', text: i18n.ts._pages.blocks.counter },
+		],
+	}, {
+		label: i18n.ts._pages.specialBlocks,
+		items: [
+			{ value: 'if', text: i18n.ts._pages.blocks.if },
+			{ value: 'post', text: i18n.ts._pages.blocks.post },
+		],
+	}];
+}
 
-			return list;
-		},
+function getScriptBlockList(type: string = null) {
+	const list = [];
 
-		setEyeCatchingImage(e) {
-			selectFile(e.currentTarget ?? e.target, null).then(file => {
-				this.eyeCatchingImageId = file.id;
+	const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
+
+	for (const block of blocks) {
+		const category = list.find(x => x.category === block.category);
+		if (category) {
+			category.items.push({
+				value: block.type,
+				text: i18n.t(`_pages.script.blocks.${block.type}`),
+			});
+		} else {
+			list.push({
+				category: block.category,
+				label: i18n.t(`_pages.script.categories.${block.category}`),
+				items: [{
+					value: block.type,
+					text: i18n.t(`_pages.script.blocks.${block.type}`),
+				}],
 			});
-		},
+		}
+	}
+
+	const userFns = variables.filter(x => x.type === 'fn');
+	if (userFns.length > 0) {
+		list.unshift({
+			label: i18n.t('_pages.script.categories.fn'),
+			items: userFns.map(v => ({
+				value: 'fn:' + v.name,
+				text: v.name,
+			})),
+		});
+	}
 
-		removeEyeCatchingImage() {
-			this.eyeCatchingImageId = null;
-		},
+	return list;
+}
 
-		highlighter(code) {
-			return highlight(code, languages.js, 'javascript');
-		},
+function setEyeCatchingImage(e) {
+	selectFile(e.currentTarget ?? e.target, null).then(file => {
+		eyeCatchingImageId = file.id;
+	});
+}
+
+function removeEyeCatchingImage() {
+	taeyeCatchingImageId = null;
+}
+
+function highlighter(code) {
+	return highlight(code, languages.js, 'javascript');
+}
+
+async function init() {
+	hpml = new HpmlTypeChecker();
+
+	watch($$(variables), () => {
+		hpml.variables = variables;
+	}, { deep: true });
+
+	watch($$(content), () => {
+		hpml.pageVars = collectPageVars(content);
+	}, { deep: true });
+
+	if (props.initPageId) {
+		page = await os.api('pages/show', {
+			pageId: props.initPageId,
+		});
+	} else if (props.initPageName && props.initUser) {
+		page = await os.api('pages/show', {
+			name: props.initPageName,
+			username: props.initUser,
+		});
+		readonly = true;
 	}
-});
+
+	if (page) {
+		author = page.user;
+		pageId = page.id;
+		title = page.title;
+		name = page.name;
+		currentName = page.name;
+		summary = page.summary;
+		font = page.font;
+		script = page.script;
+		hideTitleWhenPinned = page.hideTitleWhenPinned;
+		alignCenter = page.alignCenter;
+		content = page.content;
+		variables = page.variables;
+		eyeCatchingImageId = page.eyeCatchingImageId;
+	} else {
+		const id = uuid();
+		content = [{
+			id,
+			type: 'text',
+			text: 'Hello World!',
+		}];
+	}
+}
+
+init();
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => {
+	let title = i18n.ts._pages.newPage;
+	if (props.initPageId) {
+		title = i18n.ts._pages.editPage;
+	}
+	else if (props.initPageName && props.initUser) {
+		title = i18n.ts._pages.readPage;
+	}
+	return {
+		title: title,
+		icon: 'fas fa-pencil-alt',
+		bg: 'var(--bg)',
+		tabs: [{
+			active: tab === 'settings',
+			title: i18n.ts._pages.pageSetting,
+			icon: 'fas fa-cog',
+			onClick: () => { tab = 'settings'; },
+		}, {
+			active: tab === 'contents',
+			title: i18n.ts._pages.contents,
+			icon: 'fas fa-sticky-note',
+			onClick: () => { tab = 'contents'; },
+		}, {
+			active: tab === 'variables',
+			title: i18n.ts._pages.variables,
+			icon: 'fas fa-magic',
+			onClick: () => { tab = 'variables'; },
+		}, {
+			active: tab === 'script',
+			title: i18n.ts.script,
+			icon: 'fas fa-code',
+			onClick: () => { tab = 'script'; },
+		}],
+	};
+}));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue
index 5bca971438..71b404bbf4 100644
--- a/packages/client/src/pages/page.vue
+++ b/packages/client/src/pages/page.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="700">
 	<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
 		<div v-if="page" :key="page.id" v-size="{ max: [450] }" class="xcukqgmh">
 			<div class="_block main">
@@ -56,138 +57,108 @@
 		<MkError v-else-if="error" @retry="fetch()"/>
 		<MkLoading v-else/>
 	</transition>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, watch } from 'vue';
 import XPage from '@/components/page/page.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { url } from '@/config';
 import MkFollowButton from '@/components/follow-button.vue';
 import MkContainer from '@/components/ui/container.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkPagePreview from '@/components/page-preview.vue';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const props = defineProps<{
+	pageName: string;
+	username: string;
+}>();
+
+let page = $ref(null);
+let error = $ref(null);
+const otherPostsPagination = {
+	endpoint: 'users/pages' as const,
+	limit: 6,
+	params: computed(() => ({
+		userId: page.user.id,
+	})),
+};
+const path = $computed(() => props.username + '/' + props.pageName);
+
+function fetchPage() {
+	page = null;
+	os.api('pages/show', {
+		name: props.pageName,
+		username: props.username,
+	}).then(_page => {
+		page = _page;
+	}).catch(err => {
+		error = err;
+	});
+}
 
-export default defineComponent({
-	components: {
-		XPage,
-		MkButton,
-		MkFollowButton,
-		MkContainer,
-		MkPagination,
-		MkPagePreview,
-	},
+function share() {
+	navigator.share({
+		title: page.title ?? page.name,
+		text: page.summary,
+		url: `${url}/@${page.user.username}/pages/${page.name}`,
+	});
+}
 
-	props: {
-		pageName: {
-			type: String,
-			required: true
-		},
-		username: {
-			type: String,
-			required: true
-		},
-	},
+function shareWithNote() {
+	os.post({
+		initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,
+	});
+}
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.page ? {
-				title: computed(() => this.page.title || this.page.name),
-				avatar: this.page.user,
-				path: `/@${this.page.user.username}/pages/${this.page.name}`,
-				share: {
-					title: this.page.title || this.page.name,
-					text: this.page.summary,
-				},
-			} : null),
-			page: null,
-			error: null,
-			otherPostsPagination: {
-				endpoint: 'users/pages' as const,
-				limit: 6,
-				params: computed(() => ({
-					userId: this.page.user.id
-				})),
-			},
-		};
-	},
+function like() {
+	os.apiWithDialog('pages/like', {
+		pageId: page.id,
+	}).then(() => {
+		page.isLiked = true;
+		page.likedCount++;
+	});
+}
 
-	computed: {
-		path(): string {
-			return this.username + '/' + this.pageName;
-		}
-	},
+async function unlike() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.unlikeConfirm,
+	});
+	if (confirm.canceled) return;
+	os.apiWithDialog('pages/unlike', {
+		pageId: page.id,
+	}).then(() => {
+		page.isLiked = false;
+		page.likedCount--;
+	});
+}
 
-	watch: {
-		path() {
-			this.fetch();
-		}
-	},
+function pin(pin) {
+	os.apiWithDialog('i/update', {
+		pinnedPageId: pin ? page.id : null,
+	});
+}
 
-	created() {
-		this.fetch();
-	},
+watch(() => path, fetchPage, { immediate: true });
 
-	methods: {
-		fetch() {
-			this.page = null;
-			os.api('pages/show', {
-				name: this.pageName,
-				username: this.username,
-			}).then(page => {
-				this.page = page;
-			}).catch(err => {
-				this.error = err;
-			});
-		},
-
-		share() {
-			navigator.share({
-				title: this.page.title || this.page.name,
-				text: this.page.summary,
-				url: `${url}/@${this.page.user.username}/pages/${this.page.name}`
-			});
-		},
-
-		shareWithNote() {
-			os.post({
-				initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}`
-			});
-		},
-
-		like() {
-			os.apiWithDialog('pages/like', {
-				pageId: this.page.id,
-			}).then(() => {
-				this.page.isLiked = true;
-				this.page.likedCount++;
-			});
-		},
-
-		async unlike() {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: this.$ts.unlikeConfirm,
-			});
-			if (confirm.canceled) return;
-			os.apiWithDialog('pages/unlike', {
-				pageId: this.page.id,
-			}).then(() => {
-				this.page.isLiked = false;
-				this.page.likedCount--;
-			});
-		},
-
-		pin(pin) {
-			os.apiWithDialog('i/update', {
-				pinnedPageId: pin ? this.page.id : null,
-			});
-		}
-	}
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => page ? {
+	title: computed(() => page.title || page.name),
+	avatar: page.user,
+	path: `/@${page.user.username}/pages/${page.name}`,
+	share: {
+		title: page.title || page.name,
+		text: page.summary,
+	},
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue
index dcccf7f7c4..541c968ff4 100644
--- a/packages/client/src/pages/pages.vue
+++ b/packages/client/src/pages/pages.vue
@@ -1,86 +1,87 @@
 <template>
-<MkSpacer :content-max="700">
-	<div v-if="tab === 'featured'" class="rknalgpo">
-		<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
-			<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
-		</MkPagination>
-	</div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="700">
+		<div v-if="tab === 'featured'" class="rknalgpo">
+			<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
+				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+			</MkPagination>
+		</div>
 
-	<div v-else-if="tab === 'my'" class="rknalgpo my">
-		<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
-		<MkPagination v-slot="{items}" :pagination="myPagesPagination">
-			<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
-		</MkPagination>
-	</div>
+		<div v-else-if="tab === 'my'" class="rknalgpo my">
+			<MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+			<MkPagination v-slot="{items}" :pagination="myPagesPagination">
+				<MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/>
+			</MkPagination>
+		</div>
 
-	<div v-else-if="tab === 'liked'" class="rknalgpo">
-		<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
-			<MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
-		</MkPagination>
-	</div>
-</MkSpacer>
+		<div v-else-if="tab === 'liked'" class="rknalgpo">
+			<MkPagination v-slot="{items}" :pagination="likedPagesPagination">
+				<MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, inject } from 'vue';
 import MkPagePreview from '@/components/page-preview.vue';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-export default defineComponent({
-	components: {
-		MkPagePreview, MkPagination, MkButton
-	},
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.$ts.pages,
-				icon: 'fas fa-sticky-note',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-plus',
-					text: this.$ts.create,
-					handler: this.create,
-				}],
-				tabs: [{
-					active: this.tab === 'featured',
-					title: this.$ts._pages.featured,
-					icon: 'fas fa-fire-alt',
-					onClick: () => { this.tab = 'featured'; },
-				}, {
-					active: this.tab === 'my',
-					title: this.$ts._pages.my,
-					icon: 'fas fa-edit',
-					onClick: () => { this.tab = 'my'; },
-				}, {
-					active: this.tab === 'liked',
-					title: this.$ts._pages.liked,
-					icon: 'fas fa-heart',
-					onClick: () => { this.tab = 'liked'; },
-				},]
-			})),
-			tab: 'featured',
-			featuredPagesPagination: {
-				endpoint: 'pages/featured' as const,
-				noPaging: true,
-			},
-			myPagesPagination: {
-				endpoint: 'i/pages' as const,
-				limit: 5,
-			},
-			likedPagesPagination: {
-				endpoint: 'i/page-likes' as const,
-				limit: 5,
-			},
-		};
-	},
-	methods: {
-		create() {
-			this.$router.push(`/pages/new`);
-		}
-	}
-});
+const router = useRouter();
+
+let tab = $ref('featured');
+
+const featuredPagesPagination = {
+	endpoint: 'pages/featured' as const,
+	noPaging: true,
+};
+const myPagesPagination = {
+	endpoint: 'i/pages' as const,
+	limit: 5,
+};
+const likedPagesPagination = {
+	endpoint: 'i/page-likes' as const,
+	limit: 5,
+};
+
+function create() {
+	router.push('/pages/new');
+}
+
+const headerActions = $computed(() => [{
+	icon: 'fas fa-plus',
+	text: i18n.ts.create,
+	handler: create,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: tab === 'featured',
+	title: i18n.ts._pages.featured,
+	icon: 'fas fa-fire-alt',
+	onClick: () => { tab = 'featured'; },
+}, {
+	active: tab === 'my',
+	title: i18n.ts._pages.my,
+	icon: 'fas fa-edit',
+	onClick: () => { tab = 'my'; },
+}, {
+	active: tab === 'liked',
+	title: i18n.ts._pages.liked,
+	icon: 'fas fa-heart',
+	onClick: () => { tab = 'liked'; },
+}]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.pages,
+	icon: 'fas fa-sticky-note',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue
index 4accac4192..cba7589a38 100644
--- a/packages/client/src/pages/preview.vue
+++ b/packages/client/src/pages/preview.vue
@@ -7,16 +7,18 @@
 <script lang="ts" setup>
 import { computed } from 'vue';
 import MkSample from '@/components/sample.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.preview,
-		icon: 'fas fa-eye',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.preview,
+	icon: 'fas fa-eye',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue
index b3e2ca8d6f..39a1191caf 100644
--- a/packages/client/src/pages/reset-password.vue
+++ b/packages/client/src/pages/reset-password.vue
@@ -1,14 +1,17 @@
 <template>
-<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
-	<div class="_formRoot">
-		<FormInput v-model="password" type="password" class="_formBlock">
-			<template #prefix><i class="fas fa-lock"></i></template>
-			<template #label>{{ i18n.ts.newPassword }}</template>
-		</FormInput>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32">
+		<div class="_formRoot">
+			<FormInput v-model="password" type="password" class="_formBlock">
+				<template #prefix><i class="fas fa-lock"></i></template>
+				<template #label>{{ i18n.ts.newPassword }}</template>
+			</FormInput>
 		
-		<FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
-	</div>
-</MkSpacer>
+			<FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -16,9 +19,9 @@ import { defineAsyncComponent, onMounted } from 'vue';
 import FormInput from '@/components/form/input.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	token?: string;
@@ -31,22 +34,24 @@ async function save() {
 		token: props.token,
 		password: password,
 	});
-	router.push('/');
+	mainRouter.push('/');
 }
 
 onMounted(() => {
 	if (props.token == null) {
 		os.popup(defineAsyncComponent(() => import('@/components/forgot-password.vue')), {}, {}, 'closed');
-		router.push('/');
+		mainRouter.push('/');
 	}
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.resetPassword,
-		icon: 'fas fa-lock',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.resetPassword,
+	icon: 'fas fa-lock',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue
index 34a41b81a5..d437601475 100644
--- a/packages/client/src/pages/scratchpad.vue
+++ b/packages/client/src/pages/scratchpad.vue
@@ -19,7 +19,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import 'prismjs';
 import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
@@ -32,9 +32,9 @@ import MkContainer from '@/components/ui/container.vue';
 import MkButton from '@/components/ui/button.vue';
 import { createAiScriptEnv } from '@/scripts/aiscript/api';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const code = ref('');
 const logs = ref<any[]>([]);
@@ -67,7 +67,7 @@ async function run() {
 			logs.value.push({
 				id: Math.random(),
 				text: value.type === 'str' ? value.value : utils.valToString(value),
-				print: true
+				print: true,
 			});
 		},
 		log: (type, params) => {
@@ -75,11 +75,11 @@ async function run() {
 				case 'end': logs.value.push({
 					id: Math.random(),
 					text: utils.valToString(params.val, true),
-					print: false
+					print: false,
 				}); break;
 				default: break;
 			}
-		}
+		},
 	});
 
 	let ast;
@@ -88,7 +88,7 @@ async function run() {
 	} catch (error) {
 		os.alert({
 			type: 'error',
-			text: 'Syntax error :('
+			text: 'Syntax error :(',
 		});
 		return;
 	}
@@ -97,7 +97,7 @@ async function run() {
 	} catch (error: any) {
 		os.alert({
 			type: 'error',
-			text: error.message
+			text: error.message,
 		});
 	}
 }
@@ -106,11 +106,13 @@ function highlighter(code) {
 	return highlight(code, languages.js, 'javascript');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.scratchpad,
-		icon: 'fas fa-terminal',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.scratchpad,
+	icon: 'fas fa-terminal',
 });
 </script>
 
diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue
index ce2b7035da..25fef7af50 100644
--- a/packages/client/src/pages/search.vue
+++ b/packages/client/src/pages/search.vue
@@ -1,16 +1,17 @@
 <template>
-<div class="_section">
-	<div class="_content">
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
 		<XNotes ref="notes" :pagination="pagination"/>
-	</div>
-</div>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
 import { computed } from 'vue';
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	query: string;
@@ -23,14 +24,16 @@ const pagination = {
 	params: computed(() => ({
 		query: props.query,
 		channelId: props.channel,
-	}))
+	})),
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.t('searchWith', { q: props.query }),
-		icon: 'fas fa-search',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: i18n.t('searchWith', { q: props.query }),
+	icon: 'fas fa-search',
+	bg: 'var(--bg)',
+})));
 </script>
diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue
index 12142b4dc1..65b6233693 100644
--- a/packages/client/src/pages/settings/account-info.vue
+++ b/packages/client/src/pages/settings/account-info.vue
@@ -127,30 +127,32 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, onMounted, ref } from 'vue';
+import { onMounted, ref } from 'vue';
 import FormSection from '@/components/form/section.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const stats = ref<any>({});
 
 onMounted(() => {
 	os.api('users/stats', {
-		userId: $i!.id
+		userId: $i!.id,
 	}).then(response => {
 		stats.value = response;
 	});
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.accountInfo,
-		icon: 'fas fa-info-circle'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.accountInfo,
+	icon: 'fas fa-info-circle',
 });
 </script>
diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue
index 5e75639c55..47b816243f 100644
--- a/packages/client/src/pages/settings/accounts.vue
+++ b/packages/client/src/pages/settings/accounts.vue
@@ -21,13 +21,13 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, defineExpose, ref } from 'vue';
+import { defineAsyncComponent, ref } from 'vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { getAccounts, addAccount as addAccounts, login, $i } from '@/account';
+import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const storedAccounts = ref<any>(null);
 const accounts = ref<any>(null);
@@ -39,7 +39,7 @@ const init = async () => {
 		console.log(storedAccounts.value);
 
 		return os.api('users/show', {
-			userIds: storedAccounts.value.map(x => x.id)
+			userIds: storedAccounts.value.map(x => x.id),
 		});
 	}).then(response => {
 		accounts.value = response;
@@ -70,6 +70,10 @@ function addAccount(ev) {
 	}], ev.currentTarget ?? ev.target);
 }
 
+function removeAccount(account) {
+	_removeAccount(account.id);
+}
+
 function addExistingAccount() {
 	os.popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, {
 		done: res => {
@@ -98,12 +102,14 @@ function switchAccountWithToken(token: string) {
 	login(token);
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.accounts,
-		icon: 'fas fa-users',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.accounts,
+	icon: 'fas fa-users',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue
index e6375763f1..d94862712e 100644
--- a/packages/client/src/pages/settings/api.vue
+++ b/packages/client/src/pages/settings/api.vue
@@ -7,12 +7,12 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, defineExpose, ref } from 'vue';
+import { defineAsyncComponent, ref } from 'vue';
 import FormLink from '@/components/form/link.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const isDesktop = ref(window.innerWidth >= 1100);
 
@@ -29,17 +29,19 @@ function generateToken() {
 			os.alert({
 				type: 'success',
 				title: i18n.ts.token,
-				text: token
+				text: token,
 			});
 		},
 	}, 'closed');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'API',
-		icon: 'fas fa-key',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'API',
+	icon: 'fas fa-key',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue
index 7b0b5548d5..673e91fe6b 100644
--- a/packages/client/src/pages/settings/apps.vue
+++ b/packages/client/src/pages/settings/apps.vue
@@ -7,7 +7,7 @@
 				<div>{{ i18n.ts.nothing }}</div>
 			</div>
 		</template>
-		<template v-slot="{items}">
+		<template #default="{items}">
 			<div v-for="token in items" :key="token.id" class="_panel bfomjevm">
 				<img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/>
 				<div class="body">
@@ -38,11 +38,11 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref } from 'vue';
+import { ref } from 'vue';
 import FormPagination from '@/components/ui/pagination.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const list = ref<any>(null);
 
@@ -50,8 +50,8 @@ const pagination = {
 	endpoint: 'i/apps' as const,
 	limit: 100,
 	params: {
-		sort: '+lastUsedAt'
-	}
+		sort: '+lastUsedAt',
+	},
 };
 
 function revoke(token) {
@@ -60,12 +60,14 @@ function revoke(token) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.installedApps,
-		icon: 'fas fa-plug',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.installedApps,
+	icon: 'fas fa-plug',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue
index 20db077ceb..3e032be257 100644
--- a/packages/client/src/pages/settings/custom-css.vue
+++ b/packages/client/src/pages/settings/custom-css.vue
@@ -9,13 +9,13 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormInfo from '@/components/ui/info.vue';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const localCustomCss = ref(localStorage.getItem('customCss') ?? '');
 
@@ -35,11 +35,13 @@ watch(localCustomCss, async () => {
 	await apply();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.customCss,
-		icon: 'fas fa-code',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.customCss,
+	icon: 'fas fa-code',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue
index 2d868aa0a7..edada683ae 100644
--- a/packages/client/src/pages/settings/deck.vue
+++ b/packages/client/src/pages/settings/deck.vue
@@ -30,7 +30,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, watch } from 'vue';
+import { computed, watch } from 'vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormLink from '@/components/form/link.vue';
 import FormRadios from '@/components/form/radios.vue';
@@ -39,8 +39,8 @@ import FormGroup from '@/components/form/group.vue';
 import { deckStore } from '@/ui/deck/deck-store';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const navWindow = computed(deckStore.makeGetterSetter('navWindow'));
 const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn'));
@@ -62,7 +62,7 @@ watch(navWindow, async () => {
 async function setProfile() {
 	const { canceled, result: name } = await os.inputText({
 		title: i18n.ts._deck.profile,
-		allowEmpty: false
+		allowEmpty: false,
 	});
 	if (canceled) return;
 	
@@ -70,11 +70,13 @@ async function setProfile() {
 	unisonReload();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.deck,
-		icon: 'fas fa-columns',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.deck,
+	icon: 'fas fa-columns',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue
index e9f19aaf0b..a587c32998 100644
--- a/packages/client/src/pages/settings/delete-account.vue
+++ b/packages/client/src/pages/settings/delete-account.vue
@@ -8,13 +8,12 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose } from 'vue';
 import FormInfo from '@/components/ui/info.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
 import { signout } from '@/account';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 async function deleteAccount() {
 	{
@@ -27,12 +26,12 @@ async function deleteAccount() {
 
 	const { canceled, result: password } = await os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled) return;
 
 	await os.apiWithDialog('i/delete-account', {
-		password: password
+		password: password,
 	});
 
 	await os.alert({
@@ -42,11 +41,13 @@ async function deleteAccount() {
 	await signout();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._accountDelete.accountDelete,
-		icon: 'fas fa-exclamation-triangle',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._accountDelete.accountDelete,
+	icon: 'fas fa-exclamation-triangle',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
index 09a2537ed5..73c0384f1f 100644
--- a/packages/client/src/pages/settings/drive.vue
+++ b/packages/client/src/pages/settings/drive.vue
@@ -34,7 +34,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
 import tinycolor from 'tinycolor2';
 import FormLink from '@/components/form/link.vue';
 import FormSwitch from '@/components/form/switch.vue';
@@ -43,10 +43,10 @@ import MkKeyValue from '@/components/key-value.vue';
 import FormSplit from '@/components/form/split.vue';
 import * as os from '@/os';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import MkChart from '@/components/chart.vue';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const fetching = ref(true);
 const usage = ref<any>(null);
@@ -59,8 +59,8 @@ const meterStyle = computed(() => {
 		background: tinycolor({
 			h: 180 - (usage.value / capacity.value * 180),
 			s: 0.7,
-			l: 0.5
-		})
+			l: 0.5,
+		}),
 	};
 });
 
@@ -74,7 +74,7 @@ os.api('drive').then(info => {
 
 if (defaultStore.state.uploadFolder) {
 	os.api('drive/folders/show', {
-		folderId: defaultStore.state.uploadFolder
+		folderId: defaultStore.state.uploadFolder,
 	}).then(response => {
 		uploadFolder.value = response;
 	});
@@ -86,7 +86,7 @@ function chooseUploadFolder() {
 		os.success();
 		if (defaultStore.state.uploadFolder) {
 			uploadFolder.value = await os.api('drive/folders/show', {
-				folderId: defaultStore.state.uploadFolder
+				folderId: defaultStore.state.uploadFolder,
 			});
 		} else {
 			uploadFolder.value = null;
@@ -94,12 +94,14 @@ function chooseUploadFolder() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.drive,
-		icon: 'fas fa-cloud',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.drive,
+	icon: 'fas fa-cloud',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue
index 37f14068e2..8b67ff34dd 100644
--- a/packages/client/src/pages/settings/email.vue
+++ b/packages/client/src/pages/settings/email.vue
@@ -40,27 +40,27 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, onMounted, ref, watch } from 'vue';
+import { onMounted, ref, watch } from 'vue';
 import FormSection from '@/components/form/section.vue';
 import FormInput from '@/components/form/input.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const emailAddress = ref($i!.email);
 
 const onChangeReceiveAnnouncementEmail = (v) => {
 	os.api('i/update', {
-		receiveAnnouncementEmail: v
+		receiveAnnouncementEmail: v,
 	});
 };
 
 const saveEmailAddress = () => {
 	os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	}).then(({ canceled, result: password }) => {
 		if (canceled) return;
 		os.apiWithDialog('i/update-email', {
@@ -86,7 +86,7 @@ const saveNotificationSettings = () => {
 			...[emailNotification_follow.value ? 'follow' : null],
 			...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null],
 			...[emailNotification_groupInvited.value ? 'groupInvited' : null],
-		].filter(x => x != null)
+		].filter(x => x != null),
 	});
 };
 
@@ -100,11 +100,13 @@ onMounted(() => {
 	});
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.email,
-		icon: 'fas fa-envelope',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.email,
+	icon: 'fas fa-envelope',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
index 64b8cc3106..ac2e3a4968 100644
--- a/packages/client/src/pages/settings/general.vue
+++ b/packages/client/src/pages/settings/general.vue
@@ -48,7 +48,8 @@
 		<FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch>
 		<FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch>
 		<FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch>
-		<FormSwitch v-model="useOsNativeEmojis" class="_formBlock">{{ i18n.ts.useOsNativeEmojis }}
+		<FormSwitch v-model="useOsNativeEmojis" class="_formBlock">
+			{{ i18n.ts.useOsNativeEmojis }}
 			<div><Mfm :key="useOsNativeEmojis" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div>
 		</FormSwitch>
 		<FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch>
@@ -92,7 +93,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormSelect from '@/components/form/select.vue';
 import FormRadios from '@/components/form/radios.vue';
@@ -104,8 +105,8 @@ import { langs } from '@/config';
 import { defaultStore } from '@/store';
 import * as os from '@/os';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const lang = ref(localStorage.getItem('lang'));
 const fontSize = ref(localStorage.getItem('fontSize'));
@@ -173,16 +174,18 @@ watch([
 	aiChanMode,
 	showGapBetweenNotesInTimeline,
 	instanceTicker,
-	overridedDeviceKind
+	overridedDeviceKind,
 ], async () => {
 	await reloadAsk();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.general,
-		icon: 'fas fa-cogs',
-		bg: 'var(--bg)'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.general,
+	icon: 'fas fa-cogs',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue
index 127cbcd4c1..438ecbd330 100644
--- a/packages/client/src/pages/settings/import-export.vue
+++ b/packages/client/src/pages/settings/import-export.vue
@@ -38,15 +38,15 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref } from 'vue';
+import { ref } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import FormSection from '@/components/form/section.vue';
 import FormGroup from '@/components/form/group.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
 import { selectFile } from '@/scripts/select-file';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const excludeMutingUsers = ref(false);
 const excludeInactiveUsers = ref(false);
@@ -116,12 +116,14 @@ const importBlocking = async (ev) => {
 	os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.importAndExport,
-		icon: 'fas fa-boxes',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.importAndExport,
+	icon: 'fas fa-boxes',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
index e6670ea930..011962c2e3 100644
--- a/packages/client/src/pages/settings/index.vue
+++ b/packages/client/src/pages/settings/index.vue
@@ -1,46 +1,42 @@
 <template>
-<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
-	<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
-		<div class="header">
-			<div class="title">
-				<MkA v-if="narrow" to="/settings">{{ $ts.settings }}</MkA>
-				<template v-else>{{ $ts.settings }}</template>
-			</div>
-			<div v-if="childInfo" class="subtitle">{{ childInfo.title }}</div>
-		</div>
-		<div class="body">
-			<div v-if="!narrow || initialPage == null" class="nav">
-				<div class="baaadecd">
-					<MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
-					<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
+		<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
+			<div class="body">
+				<div v-if="!narrow || initialPage == null" class="nav">
+					<div class="baaadecd">
+						<MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+						<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+					</div>
 				</div>
-			</div>
-			<div v-if="!(narrow && initialPage == null)" class="main">
-				<div class="bkzroven">
-					<component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/>
+				<div v-if="!(narrow && initialPage == null)" class="main">
+					<div class="bkzroven">
+						<component :is="component" :key="initialPage" v-bind="pageProps"/>
+					</div>
 				</div>
 			</div>
 		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</mkstickycontainer>
 </template>
 
 <script setup lang="ts">
-import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue';
 import { i18n } from '@/i18n';
 import MkInfo from '@/components/ui/info.vue';
 import MkSuperMenu from '@/components/ui/super-menu.vue';
 import { scroll } from '@/scripts/scroll';
-import { signout } from '@/account';
+import { signout , $i } from '@/account';
 import { unisonReload } from '@/scripts/unison-reload';
-import * as symbols from '@/symbols';
 import { instance } from '@/instance';
-import { $i } from '@/account';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
-const props = defineProps<{
-  initialPage?: string
-}>();
+const props = withDefaults(defineProps<{
+  initialPage?: string;
+}>(), {
+});
 
 const indexInfo = {
 	title: i18n.ts.settings,
@@ -52,7 +48,7 @@ const INFO = ref(indexInfo);
 const el = ref<HTMLElement | null>(null);
 const childInfo = ref(null);
 
-const nav = new MisskeyNavigator();
+const router = useRouter();
 
 const narrow = ref(false);
 const NARROW_THRESHOLD = 600;
@@ -189,7 +185,7 @@ const menuDef = computed(() => [{
 			signout();
 		},
 		danger: true,
-	},],
+	}],
 }]);
 
 const pageProps = ref({});
@@ -242,7 +238,7 @@ watch(component, () => {
 
 watch(() => props.initialPage, () => {
 	if (props.initialPage == null && !narrow.value) {
-		nav.push('/settings/profile');
+		router.push('/settings/profile');
 	} else {
 		if (props.initialPage == null) {
 			INFO.value = indexInfo;
@@ -252,7 +248,7 @@ watch(() => props.initialPage, () => {
 
 watch(narrow, () => {
 	if (props.initialPage == null && !narrow.value) {
-		nav.push('/settings/profile');
+		router.push('/settings/profile');
 	}
 });
 
@@ -261,7 +257,7 @@ onMounted(() => {
 
 	narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
 	if (props.initialPage == null && !narrow.value) {
-		nav.push('/settings/profile');
+		router.push('/settings/profile');
 	}
 });
 
@@ -271,38 +267,23 @@ onUnmounted(() => {
 
 const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
 
-const pageChanged = (page) => {
-	if (page == null) {
+provideMetadataReceiver((info) => {
+	if (info == null) {
 		childInfo.value = null;
 	} else {
-		childInfo.value = page[symbols.PAGE_INFO];
+		childInfo.value = info;
 	}
-};
-
-defineExpose({
-	[symbols.PAGE_INFO]: INFO,
 });
-</script>
 
-<style lang="scss" scoped>
-.vvcocwet {
-	> .header {
-		display: flex;
-		margin-bottom: 24px;
-		font-size: 1.3em;
-		font-weight: bold;
+const headerActions = $computed(() => []);
 
-		> .title {
-			display: block;
-			width: 34%;
-		}
+const headerTabs = $computed(() => []);
 
-		> .subtitle {
-			flex: 1;
-			min-width: 0;
-		}
-	}
+definePageMetadata(INFO);
+</script>
 
+<style lang="scss" scoped>
+.vvcocwet {
 	> .body {
 		> .nav {
 			.baaadecd {
diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue
index bcc2ee85ad..d0ca85adca 100644
--- a/packages/client/src/pages/settings/instance-mute.vue
+++ b/packages/client/src/pages/settings/instance-mute.vue
@@ -10,14 +10,14 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import MkInfo from '@/components/ui/info.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const instanceMutes = ref($i!.mutedInstances.join('\n'));
 const changed = ref(false);
@@ -42,10 +42,12 @@ watch(instanceMutes, () => {
 	changed.value = true;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.instanceMute,
-		icon: 'fas fa-volume-mute'
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.instanceMute,
+	icon: 'fas fa-volume-mute',
 });
 </script>
diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue
index 75c6200944..7de151040e 100644
--- a/packages/client/src/pages/settings/integration.vue
+++ b/packages/client/src/pages/settings/integration.vue
@@ -24,14 +24,14 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, onMounted, ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 import { apiUrl } from '@/config';
 import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { instance } from '@/instance';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const twitterForm = ref<Window | null>(null);
 const discordForm = ref<Window | null>(null);
@@ -42,7 +42,7 @@ const integrations = computed(() => $i!.integrations);
 function openWindow(service: string, type: string) {
 	return window.open(`${apiUrl}/${type}/${service}`,
 		`${service}_${type}_window`,
-		'height=570, width=520'
+		'height=570, width=520',
 	);
 }
 
@@ -72,7 +72,7 @@ function disconnectGithub() {
 
 onMounted(() => {
 	document.cookie = `igi=${$i!.token}; path=/;` +
-		` max-age=31536000;` +
+		' max-age=31536000;' +
 		(document.location.protocol.startsWith('https') ? ' secure' : '');
 
 	watch(integrations, () => {
@@ -88,11 +88,13 @@ onMounted(() => {
 	});
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.integration,
-		icon: 'fas fa-share-alt',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.integration,
+	icon: 'fas fa-share-alt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue
index 2288c3f718..1b4d8799c8 100644
--- a/packages/client/src/pages/settings/menu.vue
+++ b/packages/client/src/pages/settings/menu.vue
@@ -18,16 +18,16 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormRadios from '@/components/form/radios.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
 import { menuDef } from '@/menu';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const items = ref(defaultStore.state.menu.join('\n'));
 
@@ -37,7 +37,7 @@ const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
 async function reloadAsk() {
 	const { canceled } = await os.confirm({
 		type: 'info',
-		text: i18n.ts.reloadToApplySetting
+		text: i18n.ts.reloadToApplySetting,
 	});
 	if (canceled) return;
 
@@ -49,10 +49,10 @@ async function addItem() {
 	const { canceled, result: item } = await os.select({
 		title: i18n.ts.addItem,
 		items: [...menu.map(k => ({
-			value: k, text: i18n.ts[menuDef[k].title]
+			value: k, text: i18n.ts[menuDef[k].title],
 		})), {
-			value: '-', text: i18n.ts.divider
-		}]
+			value: '-', text: i18n.ts.divider,
+		}],
 	});
 	if (canceled) return;
 	items.value = [...split.value, item].join('\n');
@@ -76,11 +76,13 @@ watch(menuDisplay, async () => {
 	await reloadAsk();
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.menu,
-		icon: 'fas fa-list-ul',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.menu,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue
index 28d11809e3..d8cb286626 100644
--- a/packages/client/src/pages/settings/mute-block.vue
+++ b/packages/client/src/pages/settings/mute-block.vue
@@ -7,7 +7,7 @@
 	<div v-if="tab === 'mute'">
 		<MkPagination :pagination="mutingPagination" class="muting">
 			<template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
 					<MkAcct :user="mute.mutee"/>
 				</FormLink>
@@ -17,7 +17,7 @@
 	<div v-if="tab === 'block'">
 		<MkPagination :pagination="blockingPagination" class="blocking">
 			<template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
 					<MkAcct :user="block.blockee"/>
 				</FormLink>
@@ -35,8 +35,8 @@ import FormInfo from '@/components/ui/info.vue';
 import FormLink from '@/components/form/link.vue';
 import { userPage } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let tab = $ref('mute');
 
@@ -50,11 +50,13 @@ const blockingPagination = {
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.muteAndBlock,
-		icon: 'fas fa-ban',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.muteAndBlock,
+	icon: 'fas fa-ban',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue
index b8fff95a8d..494a3eebe0 100644
--- a/packages/client/src/pages/settings/notifications.vue
+++ b/packages/client/src/pages/settings/notifications.vue
@@ -10,15 +10,15 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, defineExpose } from 'vue';
+import { defineAsyncComponent } from 'vue';
+import { notificationTypes } from 'misskey-js';
 import FormButton from '@/components/ui/button.vue';
 import FormLink from '@/components/form/link.vue';
 import FormSection from '@/components/form/section.vue';
-import { notificationTypes } from 'misskey-js';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 async function readAllUnreadNotes() {
 	await os.api('i/read-all-unread-notes');
@@ -45,15 +45,17 @@ function configure() {
 			}).then(i => {
 				$i!.mutingNotificationTypes = i.mutingNotificationTypes;
 			});
-		}
+		},
 	}, 'closed');
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.notifications,
-		icon: 'fas fa-bell',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.notifications,
+	icon: 'fas fa-bell',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue
index 82e174a5b4..283d87a066 100644
--- a/packages/client/src/pages/settings/other.vue
+++ b/packages/client/src/pages/settings/other.vue
@@ -15,30 +15,32 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose } from 'vue';
+import { computed } from 'vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormLink from '@/components/form/link.vue';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const reportError = computed(defaultStore.makeGetterSetter('reportError'));
 
 function onChangeInjectFeaturedNote(v) {
 	os.api('i/update', {
-		injectFeaturedNote: v
+		injectFeaturedNote: v,
 	}).then((i) => {
 		$i!.injectFeaturedNote = i.injectFeaturedNote;
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.other,
-		icon: 'fas fa-ellipsis-h',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.other,
+	icon: 'fas fa-ellipsis-h',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue
index 96c0abfd99..7ff55e9d83 100644
--- a/packages/client/src/pages/settings/plugin.install.vue
+++ b/packages/client/src/pages/settings/plugin.install.vue
@@ -13,7 +13,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, defineAsyncComponent, nextTick, ref } from 'vue';
+import { defineAsyncComponent, nextTick, ref } from 'vue';
 import { AiScript, parse } from '@syuilo/aiscript';
 import { serialize } from '@syuilo/aiscript/built/serializer';
 import { v4 as uuid } from 'uuid';
@@ -24,7 +24,7 @@ import * as os from '@/os';
 import { ColdDeviceStorage } from '@/store';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const code = ref(null);
 
@@ -35,7 +35,7 @@ function installPlugin({ id, meta, ast, token }) {
 		active: true,
 		configData: {},
 		token: token,
-		ast: ast
+		ast: ast,
 	}));
 }
 
@@ -46,7 +46,7 @@ async function install() {
 	} catch (err) {
 		os.alert({
 			type: 'error',
-			text: 'Syntax error :('
+			text: 'Syntax error :(',
 		});
 		return;
 	}
@@ -55,7 +55,7 @@ async function install() {
 	if (meta == null) {
 		os.alert({
 			type: 'error',
-			text: 'No metadata found :('
+			text: 'No metadata found :(',
 		});
 		return;
 	}
@@ -64,7 +64,7 @@ async function install() {
 	if (metadata == null) {
 		os.alert({
 			type: 'error',
-			text: 'No metadata found :('
+			text: 'No metadata found :(',
 		});
 		return;
 	}
@@ -73,7 +73,7 @@ async function install() {
 	if (name == null || version == null || author == null) {
 		os.alert({
 			type: 'error',
-			text: 'Required property not found :('
+			text: 'Required property not found :(',
 		});
 		return;
 	}
@@ -83,7 +83,7 @@ async function install() {
 			title: i18n.ts.tokenRequested,
 			information: i18n.ts.pluginTokenRequestedDescription,
 			initialName: name,
-			initialPermissions: permissions
+			initialPermissions: permissions,
 		}, {
 			done: async result => {
 				const { name, permissions } = result;
@@ -93,17 +93,17 @@ async function install() {
 					permission: permissions,
 				});
 				res(token);
-			}
+			},
 		}, 'closed');
 	});
 
 	installPlugin({
 		id: uuid(),
 		meta: {
-			name, version, author, description, permissions, config
+			name, version, author, description, permissions, config,
 		},
 		token,
-		ast: serialize(ast)
+		ast: serialize(ast),
 	});
 
 	os.success();
@@ -113,11 +113,13 @@ async function install() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._plugin.install,
-		icon: 'fas fa-download',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._plugin.install,
+	icon: 'fas fa-download',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue
index 873a022cbc..75cf42bb89 100644
--- a/packages/client/src/pages/settings/plugin.vue
+++ b/packages/client/src/pages/settings/plugin.vue
@@ -7,7 +7,7 @@
 		<div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;">
 			<span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span>
 
-			<FormSwitch class="_formBlock" :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
+			<FormSwitch class="_formBlock" :model-value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch>
 
 			<MkKeyValue class="_formBlock">
 				<template #key>{{ i18n.ts.author }}</template>
@@ -32,7 +32,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, nextTick, ref } from 'vue';
+import { nextTick, ref } from 'vue';
 import FormLink from '@/components/form/link.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormSection from '@/components/form/section.vue';
@@ -40,9 +40,9 @@ import MkButton from '@/components/ui/button.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import * as os from '@/os';
 import { ColdDeviceStorage } from '@/store';
-import * as symbols from '@/symbols';
 import { unisonReload } from '@/scripts/unison-reload';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const plugins = ref(ColdDeviceStorage.get('plugins'));
 
@@ -83,12 +83,14 @@ function changeActive(plugin, active) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.plugins,
-		icon: 'fas fa-plug',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.plugins,
+	icon: 'fas fa-plug',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue
index a84d2f8786..a209c3f469 100644
--- a/packages/client/src/pages/settings/privacy.vue
+++ b/packages/client/src/pages/settings/privacy.vue
@@ -55,9 +55,9 @@ import FormSection from '@/components/form/section.vue';
 import FormGroup from '@/components/form/group.vue';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let isLocked = $ref($i.isLocked);
 let autoAcceptFollowed = $ref($i.autoAcceptFollowed);
@@ -84,11 +84,13 @@ function save() {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.privacy,
-		icon: 'fas fa-lock-open',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.privacy,
+	icon: 'fas fa-lock-open',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
index b64dc93cc7..b662de9e3d 100644
--- a/packages/client/src/pages/settings/profile.vue
+++ b/packages/client/src/pages/settings/profile.vue
@@ -74,10 +74,10 @@ import FormSlot from '@/components/form/slot.vue';
 import { host } from '@/config';
 import { selectFile } from '@/scripts/select-file';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
 import { langmap } from '@/scripts/langmap';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const profile = reactive({
 	name: $i.name,
@@ -176,12 +176,14 @@ function changeBanner(ev) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.profile,
-		icon: 'fas fa-user',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.profile,
+	icon: 'fas fa-user',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue
index 963ac81dfa..d0fdf835cf 100644
--- a/packages/client/src/pages/settings/reaction.vue
+++ b/packages/client/src/pages/settings/reaction.vue
@@ -64,8 +64,8 @@ import FormSection from '@/components/form/section.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
 import { defaultStore } from '@/store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let reactions = $ref(JSON.parse(JSON.stringify(defaultStore.state.reactions)));
 
@@ -83,7 +83,7 @@ function remove(reaction, ev: MouseEvent) {
 		text: i18n.ts.remove,
 		action: () => {
 			reactions = reactions.filter(x => x !== reaction);
-		}
+		},
 	}], ev.currentTarget ?? ev.target);
 }
 
@@ -106,7 +106,7 @@ async function setDefault() {
 
 function chooseEmoji(ev: MouseEvent) {
 	os.pickEmoji(ev.currentTarget ?? ev.target, {
-		showPinned: false
+		showPinned: false,
 	}).then(emoji => {
 		if (!reactions.includes(emoji)) {
 			reactions.push(emoji);
@@ -120,16 +120,18 @@ watch($$(reactions), () => {
 	deep: true,
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.reaction,
-		icon: 'fas fa-laugh',
-		action: {
-			icon: 'fas fa-eye',
-			handler: preview,
-		},
-		bg: 'var(--bg)',
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.reaction,
+	icon: 'fas fa-laugh',
+	action: {
+		icon: 'fas fa-eye',
+		handler: preview,
 	},
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue
index 401648790a..57880ef3dd 100644
--- a/packages/client/src/pages/settings/security.vue
+++ b/packages/client/src/pages/settings/security.vue
@@ -13,7 +13,7 @@
 	<FormSection>
 		<template #label>{{ i18n.ts.signinHistory }}</template>
 		<MkPagination :pagination="pagination">
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<div>
 					<div v-for="item in items" :key="item.id" v-panel class="timnmucd">
 						<header>
@@ -38,15 +38,14 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose } from 'vue';
+import X2fa from './2fa.vue';
 import FormSection from '@/components/form/section.vue';
 import FormSlot from '@/components/form/slot.vue';
 import FormButton from '@/components/ui/button.vue';
 import MkPagination from '@/components/ui/pagination.vue';
-import X2fa from './2fa.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'i/signin-history' as const,
@@ -56,54 +55,56 @@ const pagination = {
 async function change() {
 	const { canceled: canceled1, result: currentPassword } = await os.inputText({
 		title: i18n.ts.currentPassword,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled1) return;
 
 	const { canceled: canceled2, result: newPassword } = await os.inputText({
 		title: i18n.ts.newPassword,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled2) return;
 
 	const { canceled: canceled3, result: newPassword2 } = await os.inputText({
 		title: i18n.ts.newPasswordRetype,
-		type: 'password'
+		type: 'password',
 	});
 	if (canceled3) return;
 
 	if (newPassword !== newPassword2) {
 		os.alert({
 			type: 'error',
-			text: i18n.ts.retypedNotMatch
+			text: i18n.ts.retypedNotMatch,
 		});
 		return;
 	}
 	
 	os.apiWithDialog('i/change-password', {
 		currentPassword,
-		newPassword
+		newPassword,
 	});
 }
 
 function regenerateToken() {
 	os.inputText({
 		title: i18n.ts.password,
-		type: 'password'
+		type: 'password',
 	}).then(({ canceled, result: password }) => {
 		if (canceled) return;
 		os.api('i/regenerate_token', {
-			password: password
+			password: password,
 		});
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.security,
-		icon: 'fas fa-lock',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.security,
+	icon: 'fas fa-lock',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue
index d01e87c1f8..bb23257d7a 100644
--- a/packages/client/src/pages/settings/sounds.vue
+++ b/packages/client/src/pages/settings/sounds.vue
@@ -18,7 +18,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
 import FormRange from '@/components/form/range.vue';
 import FormButton from '@/components/ui/button.vue';
 import FormLink from '@/components/form/link.vue';
@@ -26,8 +26,8 @@ import FormSection from '@/components/form/section.vue';
 import * as os from '@/os';
 import { ColdDeviceStorage } from '@/store';
 import { playFile } from '@/scripts/sound';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const masterVolume = computed({
 	get: () => {
@@ -35,19 +35,19 @@ const masterVolume = computed({
 	},
 	set: (value) => {
 		ColdDeviceStorage.set('sound_masterVolume', value);
-	}
+	},
 });
 
 const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up');
 
 const sounds = ref({
-    note: ColdDeviceStorage.get('sound_note'),
-    noteMy: ColdDeviceStorage.get('sound_noteMy'),
-    notification: ColdDeviceStorage.get('sound_notification'),
-    chat: ColdDeviceStorage.get('sound_chat'),
-    chatBg: ColdDeviceStorage.get('sound_chatBg'),
-    antenna: ColdDeviceStorage.get('sound_antenna'),
-    channel: ColdDeviceStorage.get('sound_channel'),
+	note: ColdDeviceStorage.get('sound_note'),
+	noteMy: ColdDeviceStorage.get('sound_noteMy'),
+	notification: ColdDeviceStorage.get('sound_notification'),
+	chat: ColdDeviceStorage.get('sound_chat'),
+	chatBg: ColdDeviceStorage.get('sound_chatBg'),
+	antenna: ColdDeviceStorage.get('sound_antenna'),
+	channel: ColdDeviceStorage.get('sound_channel'),
 });
 
 const soundsTypes = [
@@ -95,15 +95,15 @@ async function edit(type) {
 			step: 0.05,
 			textConverter: (v) => `${Math.floor(v * 100)}%`,
 			label: i18n.ts.volume,
-			default: sounds.value[type].volume
+			default: sounds.value[type].volume,
 		},
 		listen: {
 			type: 'button',
 			content: i18n.ts.listen,
 			action: (_, values) => {
 				playFile(values.type, values.volume);
-			}
-		}
+			},
+		},
 	});
 	if (canceled) return;
 
@@ -124,11 +124,13 @@ function reset() {
 	}
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.sounds,
-		icon: 'fas fa-music',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.sounds,
+	icon: 'fas fa-music',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue
index 25fa6c012b..6a863ed9e6 100644
--- a/packages/client/src/pages/settings/theme.install.vue
+++ b/packages/client/src/pages/settings/theme.install.vue
@@ -19,8 +19,8 @@ import FormButton from '@/components/ui/button.vue';
 import { applyTheme, validateTheme } from '@/scripts/theme';
 import * as os from '@/os';
 import { addTheme, getThemes } from '@/theme-store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let installThemeCode = $ref(null);
 
@@ -32,21 +32,21 @@ function parseThemeCode(code: string) {
 	} catch (err) {
 		os.alert({
 			type: 'error',
-			text: i18n.ts._theme.invalid
+			text: i18n.ts._theme.invalid,
 		});
 		return false;
 	}
 	if (!validateTheme(theme)) {
 		os.alert({
 			type: 'error',
-			text: i18n.ts._theme.invalid
+			text: i18n.ts._theme.invalid,
 		});
 		return false;
 	}
 	if (getThemes().some(t => t.id === theme.id)) {
 		os.alert({
 			type: 'info',
-			text: i18n.ts._theme.alreadyInstalled
+			text: i18n.ts._theme.alreadyInstalled,
 		});
 		return false;
 	}
@@ -65,15 +65,17 @@ async function install(code: string): Promise<void> {
 	await addTheme(theme);
 	os.alert({
 		type: 'success',
-		text: i18n.t('_theme.installed', { name: theme.name })
+		text: i18n.t('_theme.installed', { name: theme.name }),
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._theme.install,
-		icon: 'fas fa-download',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._theme.install,
+	icon: 'fas fa-download',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue
index 94b2d24455..68cbf3c353 100644
--- a/packages/client/src/pages/settings/theme.manage.vue
+++ b/packages/client/src/pages/settings/theme.manage.vue
@@ -26,7 +26,7 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, defineExpose, ref } from 'vue';
+import { computed, ref } from 'vue';
 import JSON5 from 'json5';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormSelect from '@/components/form/select.vue';
@@ -36,8 +36,8 @@ import { Theme, getBuiltinThemesRef } from '@/scripts/theme';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
 import * as os from '@/os';
 import { getThemes, removeTheme } from '@/theme-store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const installedThemes = ref(getThemes());
 const builtinThemes = getBuiltinThemesRef();
@@ -67,11 +67,13 @@ function uninstall() {
 	os.success();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts._theme.manage,
-		icon: 'fas fa-folder-open',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts._theme.manage,
+	icon: 'fas fa-folder-open',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
index 5e7ffcff4b..db4262ba7e 100644
--- a/packages/client/src/pages/settings/theme.vue
+++ b/packages/client/src/pages/settings/theme.vue
@@ -96,13 +96,12 @@ import FormButton from '@/components/ui/button.vue';
 import { getBuiltinThemesRef } from '@/scripts/theme';
 import { selectFile } from '@/scripts/select-file';
 import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
-import { ColdDeviceStorage } from '@/store';
+import { ColdDeviceStorage , defaultStore } from '@/store';
 import { i18n } from '@/i18n';
-import { defaultStore } from '@/store';
 import { instance } from '@/instance';
 import { uniqueBy } from '@/scripts/array';
 import { fetchThemes, getThemes } from '@/theme-store';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const installedThemes = ref(getThemes());
 const builtinThemes = getBuiltinThemesRef();
@@ -121,7 +120,7 @@ const darkThemeId = computed({
 	},
 	set(id) {
 		ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id));
-	}
+	},
 });
 const lightTheme = ColdDeviceStorage.ref('lightTheme');
 const lightThemeId = computed({
@@ -130,7 +129,7 @@ const lightThemeId = computed({
 	},
 	set(id) {
 		ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id));
-	}
+	},
 });
 const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
 const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
@@ -168,12 +167,14 @@ function setWallpaper(event) {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.theme,
-		icon: 'fas fa-palette',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.theme,
+	icon: 'fas fa-palette',
+	bg: 'var(--bg)',
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/webhook.edit.vue b/packages/client/src/pages/settings/webhook.edit.vue
index 3690526b41..d3cf5d7b79 100644
--- a/packages/client/src/pages/settings/webhook.edit.vue
+++ b/packages/client/src/pages/settings/webhook.edit.vue
@@ -40,19 +40,11 @@ import FormSection from '@/components/form/section.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'Edit webhook',
-		icon: 'fas fa-bolt',
-		bg: 'var(--bg)',
-	},
-});
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const webhook = await os.api('i/webhooks/show', {
-	webhookId: new URLSearchParams(window.location.search).get('id')
+	webhookId: new URLSearchParams(window.location.search).get('id'),
 });
 
 let name = $ref(webhook.name);
@@ -86,4 +78,14 @@ async function save(): Promise<void> {
 		active,
 	});
 }
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Edit webhook',
+	icon: 'fas fa-bolt',
+	bg: 'var(--bg)',
+});
 </script>
diff --git a/packages/client/src/pages/settings/webhook.new.vue b/packages/client/src/pages/settings/webhook.new.vue
index 9bb492c49e..508c0d78be 100644
--- a/packages/client/src/pages/settings/webhook.new.vue
+++ b/packages/client/src/pages/settings/webhook.new.vue
@@ -38,8 +38,8 @@ import FormSection from '@/components/form/section.vue';
 import FormSwitch from '@/components/form/switch.vue';
 import FormButton from '@/components/ui/button.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let name = $ref('');
 let url = $ref('');
@@ -71,11 +71,13 @@ async function create(): Promise<void> {
 	});
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'Create new webhook',
-		icon: 'fas fa-bolt',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Create new webhook',
+	icon: 'fas fa-bolt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/webhook.vue b/packages/client/src/pages/settings/webhook.vue
index c9af8b6766..50739e2fd1 100644
--- a/packages/client/src/pages/settings/webhook.vue
+++ b/packages/client/src/pages/settings/webhook.vue
@@ -8,7 +8,7 @@
 	
 	<FormSection>
 		<MkPagination :pagination="pagination">
-			<template v-slot="{items}">
+			<template #default="{items}">
 				<FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit?id=${webhook.id}`" class="_formBlock">
 					<template #icon>
 						<i v-if="webhook.active === false" class="fas fa-circle-pause"></i>
@@ -34,19 +34,21 @@ import FormSection from '@/components/form/section.vue';
 import FormLink from '@/components/form/link.vue';
 import { userPage } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const pagination = {
 	endpoint: 'i/webhooks/list' as const,
 	limit: 10,
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: 'Webhook',
-		icon: 'fas fa-bolt',
-		bg: 'var(--bg)',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: 'Webhook',
+	icon: 'fas fa-bolt',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue
index 6e1a4b2ccb..c6af0e7661 100644
--- a/packages/client/src/pages/settings/word-mute.vue
+++ b/packages/client/src/pages/settings/word-mute.vue
@@ -29,7 +29,7 @@
 </template>
 
 <script lang="ts" setup>
-import { defineExpose, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import MkKeyValue from '@/components/key-value.vue';
 import MkButton from '@/components/ui/button.vue';
@@ -37,10 +37,10 @@ import MkInfo from '@/components/ui/info.vue';
 import MkTab from '@/components/tab.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const render = (mutedWords) => mutedWords.map(x => {
 	if (Array.isArray(x)) {
@@ -87,7 +87,7 @@ async function save() {
 					os.alert({
 						type: 'error',
 						title: i18n.ts.regexpError,
-						text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + "\n" + err.toString()
+						text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(),
 					});
 					// re-throw error so these invalid settings are not saved
 					throw err;
@@ -117,11 +117,13 @@ async function save() {
 	changed.value = false;
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.wordMute,
-		icon: 'fas fa-comment-slash',
-		bg: 'var(--bg)',
-	}
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.wordMute,
+	icon: 'fas fa-comment-slash',
+	bg: 'var(--bg)',
 });
 </script>
diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue
index 1700944f82..8984823b60 100644
--- a/packages/client/src/pages/share.vue
+++ b/packages/client/src/pages/share.vue
@@ -22,158 +22,144 @@
 </div>
 </template>
 
-<script lang="ts">
+<script lang="ts" setup>
 // SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
 
 import { defineComponent } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import XPostForm from '@/components/post-form.vue';
-import * as os from '@/os';
 import { noteVisibilities } from 'misskey-js';
 import * as Acct from 'misskey-js/built/acct';
-import * as symbols from '@/symbols';
 import * as Misskey from 'misskey-js';
+import MkButton from '@/components/ui/button.vue';
+import XPostForm from '@/components/post-form.vue';
+import * as os from '@/os';
+import { mainRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+
+const urlParams = new URLSearchParams(window.location.search);
+const localOnlyQuery = urlParams.get('localOnly');
+const visibilityQuery = urlParams.get('visibility');
+
+let state = $ref('fetching' as 'fetching' | 'writing' | 'posted');
+let title = $ref(urlParams.get('title'));
+const text = urlParams.get('text');
+const url = urlParams.get('url');
+let initialText = $ref(null as string | null);
+let reply = $ref(null as Misskey.entities.Note | null);
+let renote = $ref(null as Misskey.entities.Note | null);
+let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null);
+let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null);
+let files = $ref([] as Misskey.entities.DriveFile[]);
+let visibleUsers = $ref([] as Misskey.entities.User[]);
+
+async function init() {
+	let noteText = '';
+	if (title) noteText += `[ ${title} ]\n`;
+	// Googleニュース対策
+	if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, '');
+	else if (text && title !== text) noteText += `${text}\n`;
+	if (url) noteText += `${url}`;
+	initialText = noteText.trim();
+
+	if (visibility === 'specified') {
+		const visibleUserIds = urlParams.get('visibleUserIds');
+		const visibleAccts = urlParams.get('visibleAccts');
+		await Promise.all(
+			[
+				...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
+				...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []),
+			]
+			// TypeScriptの指示通りに変換する
+			.map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
+			.map(q => os.api('users/show', q)
+				.then(user => {
+					visibleUsers.push(user);
+				}, () => {
+					console.error(`Invalid user query: ${JSON.stringify(q)}`);
+				}),
+			),
+		);
+	}
 
-export default defineComponent({
-	components: {
-		XPostForm,
-		MkButton,
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.share,
-				icon: 'fas fa-share-alt'
-			},
-			state: 'fetching' as 'fetching' | 'writing' | 'posted',
-
-			title: null as string | null,
-			initialText: null as string | null,
-			reply: null as Misskey.entities.Note | null,
-			renote: null as Misskey.entities.Note | null,
-			visibility: null as string | null,
-			localOnly: null as boolean | null,
-			files: [] as Misskey.entities.DriveFile[],
-			visibleUsers: [] as Misskey.entities.User[],
-		};
-	},
-
-	async created() {
-		const urlParams = new URLSearchParams(window.location.search);
-
-		this.title = urlParams.get('title');
-		const text = urlParams.get('text');
-		const url = urlParams.get('url');
-
-		let noteText = '';
-		if (this.title) noteText += `[ ${this.title} ]\n`;
-		// Googleニュース対策
-		if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, '');
-		else if (text && this.title !== text) noteText += `${text}\n`;
-		if (url) noteText += `${url}`;
-		this.initialText = noteText.trim();
-
-		const visibility = urlParams.get('visibility');
-		if (noteVisibilities.includes(visibility)) {
-			this.visibility = visibility;
+	try {
+		//#region Reply
+		const replyId = urlParams.get('replyId');
+		const replyUri = urlParams.get('replyUri');
+		if (replyId) {
+			reply = await os.api('notes/show', {
+				noteId: replyId,
+			});
+		} else if (replyUri) {
+			const obj = await os.api('ap/show', {
+				uri: replyUri,
+			});
+			if (obj.type === 'Note') {
+				reply = obj.object;
+			}
+		}
+		//#endregion
+
+		//#region Renote
+		const renoteId = urlParams.get('renoteId');
+		const renoteUri = urlParams.get('renoteUri');
+		if (renoteId) {
+			renote = await os.api('notes/show', {
+				noteId: renoteId,
+			});
+		} else if (renoteUri) {
+			const obj = await os.api('ap/show', {
+				uri: renoteUri,
+			});
+			if (obj.type === 'Note') {
+				renote = obj.object;
+			}
 		}
+		//#endregion
 
-		if (this.visibility === 'specified') {
-			const visibleUserIds = urlParams.get('visibleUserIds');
-			const visibleAccts = urlParams.get('visibleAccts');
+		//#region Drive files
+		const fileIds = urlParams.get('fileIds');
+		if (fileIds) {
 			await Promise.all(
-				[
-					...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
-					...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : [])
-				]
-				// TypeScriptの指示通りに変換する
-				.map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
-				.map(q => os.api('users/show', q)
-					.then(user => {
-						this.visibleUsers.push(user);
+				fileIds.split(',')
+				.map(fileId => os.api('drive/files/show', { fileId })
+					.then(file => {
+						files.push(file);
 					}, () => {
-						console.error(`Invalid user query: ${JSON.stringify(q)}`);
-					})
-				)
+						console.error(`Failed to fetch a file ${fileId}`);
+					}),
+				),
 			);
 		}
+		//#endregion
+	} catch (err) {
+		os.alert({
+			type: 'error',
+			title: err.message,
+			text: err.name,
+		});
+	}
 
-		const localOnly = urlParams.get('localOnly');
-		if (localOnly === '0') this.localOnly = false;
-		else if (localOnly === '1') this.localOnly = true;
-
-		try {
-			//#region Reply
-			const replyId = urlParams.get('replyId');
-			const replyUri = urlParams.get('replyUri');
-			if (replyId) {
-				this.reply = await os.api('notes/show', {
-					noteId: replyId
-				});
-			} else if (replyUri) {
-				const obj = await os.api('ap/show', {
-					uri: replyUri
-				});
-				if (obj.type === 'Note') {
-					this.reply = obj.object;
-				}
-			}
-			//#endregion
-
-			//#region Renote
-			const renoteId = urlParams.get('renoteId');
-			const renoteUri = urlParams.get('renoteUri');
-			if (renoteId) {
-				this.renote = await os.api('notes/show', {
-					noteId: renoteId
-				});
-			} else if (renoteUri) {
-				const obj = await os.api('ap/show', {
-					uri: renoteUri
-				});
-				if (obj.type === 'Note') {
-					this.renote = obj.object;
-				}
-			}
-			//#endregion
-
-			//#region Drive files
-			const fileIds = urlParams.get('fileIds');
-			if (fileIds) {
-				await Promise.all(
-					fileIds.split(',')
-					.map(fileId => os.api('drive/files/show', { fileId })
-						.then(file => {
-							this.files.push(file);
-						}, () => {
-							console.error(`Failed to fetch a file ${fileId}`);
-						})
-					)
-				);
-			}
-			//#endregion
-		} catch (err) {
-			os.alert({
-				type: 'error',
-				title: err.message,
-				text: err.name
-			});
-		}
+	state = 'writing';
+}
 
-		this.state = 'writing';
-	},
+init();
 
-	methods: {
-		close() {
-			window.close();
+function close(): void {
+	window.close();
 
-			// 閉じなければ100ms後タイムラインに
-			window.setTimeout(() => {
-				this.$router.push('/');
-			}, 100);
-		}
-	}
+	// 閉じなければ100ms後タイムラインに
+	window.setTimeout(() => {
+		mainRouter.push('/');
+	}, 100);
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.share,
+	icon: 'fas fa-share-alt',
 });
 </script>
 
diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue
index 344c9195f7..a97990c129 100644
--- a/packages/client/src/pages/signup-complete.vue
+++ b/packages/client/src/pages/signup-complete.vue
@@ -7,9 +7,9 @@
 <script lang="ts" setup>
 import { onMounted } from 'vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { login } from '@/account';
 import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	code: string;
@@ -26,11 +26,13 @@ onMounted(async () => {
 	login(res.i, '/');
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.signup,
-		icon: 'fas fa-user',
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.signup,
+	icon: 'fas fa-user',
 });
 </script>
 
diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue
index 045f1ef259..d63864ed5c 100644
--- a/packages/client/src/pages/tag.vue
+++ b/packages/client/src/pages/tag.vue
@@ -7,7 +7,7 @@
 <script lang="ts" setup>
 import { computed } from 'vue';
 import XNotes from '@/components/notes.vue';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const props = defineProps<{
 	tag: string;
@@ -21,11 +21,13 @@ const pagination = {
 	})),
 };
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: props.tag,
-		icon: 'fas fa-hashtag',
-		bg: 'var(--bg)',
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: props.tag,
+	icon: 'fas fa-hashtag',
+	bg: 'var(--bg)',
+})));
 </script>
diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue
index 2a11c07fd2..38f3b90a6e 100644
--- a/packages/client/src/pages/theme-editor.vue
+++ b/packages/client/src/pages/theme-editor.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+<template><MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+		<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
 	<div class="cwepdizn _formRoot">
 		<FormFolder :default-open="true" class="_formBlock">
 			<template #label>{{ i18n.ts.backgroundColor }}</template>
@@ -61,7 +62,7 @@
 			</div>
 		</FormFolder>
 	</div>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
 </template>
 
 <script lang="ts" setup>
@@ -82,7 +83,6 @@ import { host } from '@/config';
 import * as os from '@/os';
 import { ColdDeviceStorage, defaultStore } from '@/store';
 import { addTheme } from '@/theme-store';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 import { useLeaveGuard } from '@/scripts/use-leave-guard';
 
@@ -204,23 +204,25 @@ async function saveAs() {
 
 watch($$(theme), apply, { deep: true });
 
-defineExpose({
-	[symbols.PAGE_INFO]: {
-		title: i18n.ts.themeEditor,
-		icon: 'fas fa-palette',
-		bg: 'var(--bg)',
-		actions: [{
-			asFullButton: true,
-			icon: 'fas fa-eye',
-			text: i18n.ts.preview,
-			handler: showPreview,
-		}, {
-			asFullButton: true,
-			icon: 'fas fa-check',
-			text: i18n.ts.saveAs,
-			handler: saveAs,
-		}],
-	},
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.themeEditor,
+	icon: 'fas fa-palette',
+	bg: 'var(--bg)',
+	actions: [{
+		asFullButton: true,
+		icon: 'fas fa-eye',
+		text: i18n.ts.preview,
+		handler: showPreview,
+	}, {
+		asFullButton: true,
+		icon: 'fas fa-check',
+		text: i18n.ts.saveAs,
+		handler: saveAs,
+	}],
 });
 </script>
 
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
index fe3dbc3cff..e0f0a1f151 100644
--- a/packages/client/src/pages/timeline.vue
+++ b/packages/client/src/pages/timeline.vue
@@ -1,39 +1,37 @@
 <template>
-<MkSpacer :content-max="800">
-	<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
-		<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
-		<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
-
-		<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
-		<div class="tl _block">
-			<XTimeline ref="tl" :key="src"
-				class="tl"
-				:src="src"
-				:sound="true"
-				@queue="queueUpdated"
-			/>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="800">
+		<div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf">
+			<XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
+			<XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
+
+			<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+			<div class="tl _block">
+				<XTimeline
+					ref="tl" :key="src"
+					class="tl"
+					:src="src"
+					:sound="true"
+					@queue="queueUpdated"
+				/>
+			</div>
 		</div>
-	</div>
-</MkSpacer>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-export default {
-	name: 'MkTimelinePage',
-};
-</script>
-
 <script lang="ts" setup>
 import { defineAsyncComponent, computed, watch } from 'vue';
 import XTimeline from '@/components/timeline.vue';
 import XPostForm from '@/components/post-form.vue';
 import { scroll } from '@/scripts/scroll';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import { i18n } from '@/i18n';
 import { instance } from '@/instance';
 import { $i } from '@/account';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
 
@@ -111,55 +109,55 @@ function focus(): void {
 	tlComponent.focus();
 }
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: i18n.ts.timeline,
-		icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
-		bg: 'var(--bg)',
-		actions: [{
-			icon: 'fas fa-list-ul',
-			text: i18n.ts.lists,
-			handler: chooseList,
-		}, {
-			icon: 'fas fa-satellite',
-			text: i18n.ts.antennas,
-			handler: chooseAntenna,
-		}, {
-			icon: 'fas fa-satellite-dish',
-			text: i18n.ts.channel,
-			handler: chooseChannel,
-		}, {
-			icon: 'fas fa-calendar-alt',
-			text: i18n.ts.jumpToSpecifiedDate,
-			handler: timetravel,
-		}],
-		tabs: [{
-			active: src === 'home',
-			title: i18n.ts._timelines.home,
-			icon: 'fas fa-home',
-			iconOnly: true,
-			onClick: () => { saveSrc('home'); },
-		}, ...(isLocalTimelineAvailable ? [{
-			active: src === 'local',
-			title: i18n.ts._timelines.local,
-			icon: 'fas fa-comments',
-			iconOnly: true,
-			onClick: () => { saveSrc('local'); },
-		}, {
-			active: src === 'social',
-			title: i18n.ts._timelines.social,
-			icon: 'fas fa-share-alt',
-			iconOnly: true,
-			onClick: () => { saveSrc('social'); },
-		}] : []), ...(isGlobalTimelineAvailable ? [{
-			active: src === 'global',
-			title: i18n.ts._timelines.global,
-			icon: 'fas fa-globe',
-			iconOnly: true,
-			onClick: () => { saveSrc('global'); },
-		}] : [])],
-	})),
-});
+const headerActions = $computed(() => [{
+	icon: 'fas fa-list-ul',
+	text: i18n.ts.lists,
+	handler: chooseList,
+}, {
+	icon: 'fas fa-satellite',
+	text: i18n.ts.antennas,
+	handler: chooseAntenna,
+}, {
+	icon: 'fas fa-satellite-dish',
+	text: i18n.ts.channel,
+	handler: chooseChannel,
+}, {
+	icon: 'fas fa-calendar-alt',
+	text: i18n.ts.jumpToSpecifiedDate,
+	handler: timetravel,
+}]);
+
+const headerTabs = $computed(() => [{
+	active: src === 'home',
+	title: i18n.ts._timelines.home,
+	icon: 'fas fa-home',
+	iconOnly: true,
+	onClick: () => { saveSrc('home'); },
+}, ...(isLocalTimelineAvailable ? [{
+	active: src === 'local',
+	title: i18n.ts._timelines.local,
+	icon: 'fas fa-comments',
+	iconOnly: true,
+	onClick: () => { saveSrc('local'); },
+}, {
+	active: src === 'social',
+	title: i18n.ts._timelines.social,
+	icon: 'fas fa-share-alt',
+	iconOnly: true,
+	onClick: () => { saveSrc('social'); },
+}] : []), ...(isGlobalTimelineAvailable ? [{
+	active: src === 'global',
+	title: i18n.ts._timelines.global,
+	icon: 'fas fa-globe',
+	iconOnly: true,
+	onClick: () => { saveSrc('global'); },
+}] : [])]);
+
+definePageMetadata(computed(() => ({
+	title: i18n.ts.timeline,
+	icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
index 54e1f13021..6dc25e99d6 100644
--- a/packages/client/src/pages/user-info.vue
+++ b/packages/client/src/pages/user-info.vue
@@ -1,71 +1,75 @@
 <template>
-<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
-	<FormSuspense :p="init">
-		<div class="_formRoot">
-			<div class="_formBlock aeakzknw">
-				<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
-			</div>
-
-			<FormLink :to="userPage(user)">Profile</FormLink>
-
-			<div class="_formBlock">
-				<MkKeyValue :copy="acct(user)" oneline style="margin: 1em 0;">
-					<template #key>Acct</template>
-					<template #value><span class="_monospace">{{ acct(user) }}</span></template>
-				</MkKeyValue>
-
-				<MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
-					<template #key>ID</template>
-					<template #value><span class="_monospace">{{ user.id }}</span></template>
-				</MkKeyValue>
-			</div>
-
-			<FormSection v-if="iAmModerator">
-				<template #label>Moderation</template>
-				<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch>
-				<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
-				<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
-				{{ $ts.reflectMayTakeTime }}
-				<FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
-			</FormSection>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="500" :margin-min="16" :margin-max="32">
+		<FormSuspense :p="init">
+			<div class="_formRoot">
+				<div class="_formBlock aeakzknw">
+					<MkAvatar class="avatar" :user="user" :show-indicator="true"/>
+				</div>
 
-			<FormSection>
-				<template #label>ActivityPub</template>
+				<FormLink :to="userPage(user)">Profile</FormLink>
 
 				<div class="_formBlock">
-					<MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
-						<template #key>{{ $ts.instanceInfo }}</template>
-						<template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template>
-					</MkKeyValue>
-					<MkKeyValue v-else oneline style="margin: 1em 0;">
-						<template #key>{{ $ts.instanceInfo }}</template>
-						<template #value>(Local user)</template>
-					</MkKeyValue>
-					<MkKeyValue oneline style="margin: 1em 0;">
-						<template #key>{{ $ts.updatedAt }}</template>
-						<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+					<MkKeyValue :copy="acct(user)" oneline style="margin: 1em 0;">
+						<template #key>Acct</template>
+						<template #value><span class="_monospace">{{ acct(user) }}</span></template>
 					</MkKeyValue>
-					<MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
-						<template #key>Type</template>
-						<template #value><span class="_monospace">{{ ap.type }}</span></template>
+
+					<MkKeyValue :copy="user.id" oneline style="margin: 1em 0;">
+						<template #key>ID</template>
+						<template #value><span class="_monospace">{{ user.id }}</span></template>
 					</MkKeyValue>
 				</div>
 
-				<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
-			</FormSection>
-
-			<MkObjectView v-if="info && $i.isAdmin" tall :value="info">
-			</MkObjectView>
-
-			<MkObjectView tall :value="user">
-			</MkObjectView>
-		</div>
-	</FormSuspense>
-</MkSpacer>
+				<FormSection v-if="iAmModerator">
+					<template #label>Moderation</template>
+					<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch>
+					<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
+					<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
+					{{ $ts.reflectMayTakeTime }}
+					<FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+				</FormSection>
+
+				<FormSection>
+					<template #label>ActivityPub</template>
+
+					<div class="_formBlock">
+						<MkKeyValue v-if="user.host" oneline style="margin: 1em 0;">
+							<template #key>{{ $ts.instanceInfo }}</template>
+							<template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template>
+						</MkKeyValue>
+						<MkKeyValue v-else oneline style="margin: 1em 0;">
+							<template #key>{{ $ts.instanceInfo }}</template>
+							<template #value>(Local user)</template>
+						</MkKeyValue>
+						<MkKeyValue oneline style="margin: 1em 0;">
+							<template #key>{{ $ts.updatedAt }}</template>
+							<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+						</MkKeyValue>
+						<MkKeyValue v-if="ap" oneline style="margin: 1em 0;">
+							<template #key>Type</template>
+							<template #value><span class="_monospace">{{ ap.type }}</span></template>
+						</MkKeyValue>
+					</div>
+
+					<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
+				</FormSection>
+
+				<MkObjectView v-if="info && $i.isAdmin" tall :value="info">
+				</MkObjectView>
+
+				<MkObjectView tall :value="user">
+				</MkObjectView>
+			</div>
+		</FormSuspense>
+	</MkSpacer>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { computed, defineAsyncComponent, defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, defineComponent, watch } from 'vue';
+import * as misskey from 'misskey-js';
 import MkObjectView from '@/components/object-view.vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import FormSwitch from '@/components/form/switch.vue';
@@ -77,174 +81,145 @@ import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os';
 import number from '@/filters/number';
 import bytes from '@/filters/bytes';
-import * as symbols from '@/symbols';
 import { url } from '@/config';
 import { userPage, acct } from '@/filters/user';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { iAmModerator } from '@/account';
+
+const props = defineProps<{
+	userId: string;
+}>();
+
+let user = $ref<null | misskey.entities.UserDetailed>();
+let init = $ref();
+let info = $ref();
+let ap = $ref(null);
+let moderator = $ref(false);
+let silenced = $ref(false);
+let suspended = $ref(false);
+
+function createFetcher() {
+	if (iAmModerator) {
+		return () => Promise.all([os.api('users/show', {
+			userId: props.userId,
+		}), os.api('admin/show-user', {
+			userId: props.userId,
+		})]).then(([_user, _info]) => {
+			user = _user;
+			info = _info;
+			moderator = info.isModerator;
+			silenced = info.isSilenced;
+			suspended = info.isSuspended;
+		});
+	} else {
+		return () => os.api('users/show', {
+			userId: props.userId,
+		}).then((res) => {
+			user = res;
+		});
+	}
+}
 
-export default defineComponent({
-	components: {
-		FormSection,
-		FormTextarea,
-		FormSwitch,
-		MkObjectView,
-		FormButton,
-		FormLink,
-		MkKeyValue,
-		FormSuspense,
-	},
-
-	props: {
-		userId: {
-			type: String,
-			required: true
-		}
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => ({
-				title: this.user ? acct(this.user) : this.$ts.userInfo,
-				icon: 'fas fa-info-circle',
-				bg: 'var(--bg)',
-				actions: this.user ? [this.user.url ? {
-					text: this.user.url,
-					icon: 'fas fa-external-link-alt',
-					handler: () => {
-						window.open(this.user.url, '_blank');
-					}
-				} : undefined].filter(x => x !== undefined) : [],
-			})),
-			init: null,
-			user: null,
-			info: null,
-			ap: null,
-			moderator: false,
-			silenced: false,
-			suspended: false,
-		};
-	},
+function refreshUser() {
+	init = createFetcher();
+}
 
-	computed: {
-		iAmModerator(): boolean {
-			return this.$i && (this.$i.isAdmin || this.$i.isModerator);
-		}
-	},
+async function updateRemoteUser() {
+	await os.apiWithDialog('federation/update-remote-user', { userId: user.id });
+	refreshUser();
+}
 
-	watch: {
-		userId: {
-			handler() {
-				this.init = this.createFetcher();
-			},
-			immediate: true
-		},
-		user() {
-			os.api('ap/get', {
-				uri: this.user.uri || `${url}/users/${this.user.id}`
-			}).then(res => {
-				this.ap = res;
-			});
-		}
-	},
+async function resetPassword() {
+	const { password } = await os.api('admin/reset-password', {
+		userId: user.id,
+	});
 
-	methods: {
-		number,
-		bytes,
-		userPage,
-		acct,
+	os.alert({
+		type: 'success',
+		text: i18n.t('newPasswordIs', { password }),
+	});
+}
 
-		createFetcher() {
-			if (this.iAmModerator) {
-				return () => Promise.all([os.api('users/show', {
-					userId: this.userId
-				}), os.api('admin/show-user', {
-					userId: this.userId
-				})]).then(([user, info]) => {
-					this.user = user;
-					this.info = info;
-					this.moderator = this.info.isModerator;
-					this.silenced = this.info.isSilenced;
-					this.suspended = this.info.isSuspended;
-				});
-			} else {
-				return () => os.api('users/show', {
-					userId: this.userId
-				}).then((user) => {
-					this.user = user;
-				});
-			}
-		},
+async function toggleSilence(v) {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
+	});
+	if (confirm.canceled) {
+		silenced = !v;
+	} else {
+		await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id });
+		await refreshUser();
+	}
+}
 
-		refreshUser() {
-			this.init = this.createFetcher();
-		},
+async function toggleSuspend(v) {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm,
+	});
+	if (confirm.canceled) {
+		suspended = !v;
+	} else {
+		await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id });
+		await refreshUser();
+	}
+}
 
-		async updateRemoteUser() {
-			await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id });
-			this.refreshUser();
-		},
+async function toggleModerator(v) {
+	await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id });
+	await refreshUser();
+}
 
-		async resetPassword() {
-			const { password } = await os.api('admin/reset-password', {
-				userId: this.user.id,
-			});
+async function deleteAllFiles() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteAllFilesConfirm,
+	});
+	if (confirm.canceled) return;
+	const process = async () => {
+		await os.api('admin/delete-all-files-of-a-user', { userId: user.id });
+		os.success();
+	};
+	await process().catch(err => {
+		os.alert({
+			type: 'error',
+			text: err.toString(),
+		});
+	});
+	await refreshUser();
+}
 
-			os.alert({
-				type: 'success',
-				text: this.$t('newPasswordIs', { password })
-			});
-		},
+watch(() => props.userId, () => {
+	init = createFetcher();
+}, {
+	immediate: true,
+});
 
-		async toggleSilence(v) {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm,
-			});
-			if (confirm.canceled) {
-				this.silenced = !v;
-			} else {
-				await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
-				await this.refreshUser();
-			}
-		},
+watch(() => user, () => {
+	os.api('ap/get', {
+		uri: user.uri || `${url}/users/${user.id}`,
+	}).then(res => {
+		ap = res;
+	});
+});
 
-		async toggleSuspend(v) {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm,
-			});
-			if (confirm.canceled) {
-				this.suspended = !v;
-			} else {
-				await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
-				await this.refreshUser();
-			}
-		},
+const headerActions = $computed(() => user && user.url ? [{
+	text: user.url,
+	icon: 'fas fa-external-link-alt',
+	handler: () => {
+		window.open(user.url, '_blank');
+	},
+}] : []);
 
-		async toggleModerator(v) {
-			await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
-			await this.refreshUser();
-		},
+const headerTabs = $computed(() => []);
 
-		async deleteAllFiles() {
-			const confirm = await os.confirm({
-				type: 'warning',
-				text: this.$ts.deleteAllFilesConfirm,
-			});
-			if (confirm.canceled) return;
-			const process = async () => {
-				await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
-				os.success();
-			};
-			await process().catch(err => {
-				os.alert({
-					type: 'error',
-					text: err.toString(),
-				});
-			});
-			await this.refreshUser();
-		},
-	}
-});
+definePageMetadata(computed(() => ({
+	title: user ? acct(user) : i18n.ts.userInfo,
+	icon: 'fas fa-info-circle',
+	bg: 'var(--bg)',
+})));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue
index 4476567cfb..9783088a19 100644
--- a/packages/client/src/pages/user-list-timeline.vue
+++ b/packages/client/src/pages/user-list-timeline.vue
@@ -1,8 +1,9 @@
 <template>
-<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="eqqrhokj">
+<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="eqqrhokj">
 	<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
 	<div class="tl _block">
-		<XTimeline ref="tl" :key="listId"
+		<XTimeline
+			ref="tlEl" :key="listId"
 			class="tl"
 			src="list"
 			:list="listId"
@@ -13,92 +14,71 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
+<script lang="ts" setup>
+import { computed, watch, inject } from 'vue';
 import XTimeline from '@/components/timeline.vue';
 import { scroll } from '@/scripts/scroll';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		XTimeline,
-	},
+const router = useRouter();
 
-	props: {
-		listId: {
-			type: String,
-			required: true
-		}
-	},
-
-	data() {
-		return {
-			list: null,
-			queue: 0,
-			[symbols.PAGE_INFO]: computed(() => this.list ? {
-				title: this.list.name,
-				icon: 'fas fa-list-ul',
-				bg: 'var(--bg)',
-				actions: [{
-					icon: 'fas fa-calendar-alt',
-					text: this.$ts.jumpToSpecifiedDate,
-					handler: this.timetravel
-				}, {
-					icon: 'fas fa-cog',
-					text: this.$ts.settings,
-					handler: this.settings
-				}],
-			} : null),
-		};
-	},
-
-	computed: {
-		keymap(): any {
-			return {
-				't': this.focus
-			};
-		},
-	},
-
-	watch: {
-		listId: {
-			async handler() {
-				this.list = await os.api('users/lists/show', {
-					listId: this.listId
-				});
-			},
-			immediate: true
-		}
-	},
+const props = defineProps<{
+	listId: string;
+}>();
+
+let list = $ref(null);
+let queue = $ref(0);
+let tlEl = $ref<InstanceType<typeof XTimeline>>();
+let rootEl = $ref<HTMLElement>();
 
-	methods: {
-		queueUpdated(q) {
-			this.queue = q;
-		},
+watch(() => props.listId, async () => {
+	list = await os.api('users/lists/show', {
+		listId: props.listId,
+	});
+}, { immediate: true });
+
+function queueUpdated(q) {
+	queue = q;
+}
 
-		top() {
-			scroll(this.$el, { top: 0 });
-		},
+function top() {
+	scroll(rootEl, { top: 0 });
+}
 
-		settings() {
-			this.$router.push(`/my/lists/${this.listId}`);
-		},
+function settings() {
+	router.push(`/my/lists/${props.listId}`);
+}
 
-		async timetravel() {
-			const { canceled, result: date } = await os.inputDate({
-				title: this.$ts.date,
-			});
-			if (canceled) return;
+async function timetravel() {
+	const { canceled, result: date } = await os.inputDate({
+		title: i18n.ts.date,
+	});
+	if (canceled) return;
 
-			this.$refs.tl.timetravel(date);
-		},
+	tlEl.timetravel(date);
+}
 
-		focus() {
-			(this.$refs.tl as any).focus();
-		}
-	}
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => list ? {
+	title: list.name,
+	icon: 'fas fa-list-ul',
+	bg: 'var(--bg)',
+	actions: [{
+		icon: 'fas fa-calendar-alt',
+		text: i18n.ts.jumpToSpecifiedDate,
+		handler: timetravel,
+	}, {
+		icon: 'fas fa-cog',
+		text: i18n.ts.settings,
+		handler: settings,
+	}],
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
index a024dd28bc..7b2e2cde1a 100644
--- a/packages/client/src/pages/user/index.vue
+++ b/packages/client/src/pages/user/index.vue
@@ -1,131 +1,136 @@
 <template>
-<div>
-	<transition name="fade" mode="out-in">
-		<MkSpacer v-if="user" :content-max="narrow ? 800 : 1100">
-			<div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
-				<div class="main">
-					<!-- TODO -->
-					<!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
-					<!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
-
-					<div class="profile">
-						<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
-
-						<div :key="user.id" class="_block main">
-							<div class="banner-container" :style="style">
-								<div ref="banner" class="banner" :style="style"></div>
-								<div class="fade"></div>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<div ref="rootEl">
+		<transition name="fade" mode="out-in">
+			<MkSpacer v-if="user" :content-max="narrow ? 800 : 1100">
+				<div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
+					<div class="main">
+						<!-- TODO -->
+						<!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
+						<!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
+
+						<div class="profile">
+							<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
+
+							<div :key="user.id" class="_block main">
+								<div class="banner-container" :style="style">
+									<div ref="bannerEl" class="banner" :style="style"></div>
+									<div class="fade"></div>
+									<div class="title">
+										<MkUserName class="name" :user="user" :nowrap="true"/>
+										<div class="bottom">
+											<span class="username"><MkAcct :user="user" :detail="true"/></span>
+											<span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+											<span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+											<span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+											<span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+										</div>
+									</div>
+									<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
+									<div v-if="$i" class="actions">
+										<button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
+										<MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+									</div>
+								</div>
+								<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
 								<div class="title">
-									<MkUserName class="name" :user="user" :nowrap="true"/>
+									<MkUserName :user="user" :nowrap="false" class="name"/>
 									<div class="bottom">
-										<span class="username"><MkAcct :user="user" :detail="true" /></span>
+										<span class="username"><MkAcct :user="user" :detail="true"/></span>
 										<span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
 										<span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
 										<span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
 										<span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
 									</div>
 								</div>
-								<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
-								<div v-if="$i" class="actions">
-									<button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button>
-									<MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+								<div class="description">
+									<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+									<p v-else class="empty">{{ $ts.noAccountDescription }}</p>
 								</div>
-							</div>
-							<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
-							<div class="title">
-								<MkUserName :user="user" :nowrap="false" class="name"/>
-								<div class="bottom">
-									<span class="username"><MkAcct :user="user" :detail="true" /></span>
-									<span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
-									<span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
-									<span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
-									<span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+								<div class="fields system">
+									<dl v-if="user.location" class="field">
+										<dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+										<dd class="value">{{ user.location }}</dd>
+									</dl>
+									<dl v-if="user.birthday" class="field">
+										<dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+										<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+									</dl>
+									<dl class="field">
+										<dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
+										<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+									</dl>
+								</div>
+								<div v-if="user.fields.length > 0" class="fields">
+									<dl v-for="(field, i) in user.fields" :key="i" class="field">
+										<dt class="name">
+											<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+										</dt>
+										<dd class="value">
+											<Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
+										</dd>
+									</dl>
+								</div>
+								<div class="status">
+									<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
+										<b>{{ number(user.notesCount) }}</b>
+										<span>{{ $ts.notes }}</span>
+									</MkA>
+									<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+										<b>{{ number(user.followingCount) }}</b>
+										<span>{{ $ts.following }}</span>
+									</MkA>
+									<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+										<b>{{ number(user.followersCount) }}</b>
+										<span>{{ $ts.followers }}</span>
+									</MkA>
 								</div>
-							</div>
-							<div class="description">
-								<Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
-								<p v-else class="empty">{{ $ts.noAccountDescription }}</p>
-							</div>
-							<div class="fields system">
-								<dl v-if="user.location" class="field">
-									<dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
-									<dd class="value">{{ user.location }}</dd>
-								</dl>
-								<dl v-if="user.birthday" class="field">
-									<dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
-									<dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
-								</dl>
-								<dl class="field">
-									<dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
-									<dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
-								</dl>
-							</div>
-							<div v-if="user.fields.length > 0" class="fields">
-								<dl v-for="(field, i) in user.fields" :key="i" class="field">
-									<dt class="name">
-										<Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
-									</dt>
-									<dd class="value">
-										<Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
-									</dd>
-								</dl>
-							</div>
-							<div class="status">
-								<MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }">
-									<b>{{ number(user.notesCount) }}</b>
-									<span>{{ $ts.notes }}</span>
-								</MkA>
-								<MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
-									<b>{{ number(user.followingCount) }}</b>
-									<span>{{ $ts.following }}</span>
-								</MkA>
-								<MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
-									<b>{{ number(user.followersCount) }}</b>
-									<span>{{ $ts.followers }}</span>
-								</MkA>
 							</div>
 						</div>
-					</div>
 
-					<div class="contents">
-						<template v-if="page === 'index'">
-							<div>
-								<div v-if="user.pinnedNotes.length > 0" class="_gap">
-									<XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
+						<div class="contents">
+							<template v-if="page === 'index'">
+								<div>
+									<div v-if="user.pinnedNotes.length > 0" class="_gap">
+										<XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/>
+									</div>
+									<MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
+									<template v-if="narrow">
+										<XPhotos :key="user.id" :user="user"/>
+										<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
+									</template>
 								</div>
-								<MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
-								<template v-if="narrow">
-									<XPhotos :key="user.id" :user="user"/>
-									<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
-								</template>
-							</div>
-							<div>
-								<XUserTimeline :user="user"/>
-							</div>
-						</template>
-						<XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
-						<XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
-						<XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
-						<XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
-						<XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
-						<XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
+								<div>
+									<XUserTimeline :user="user"/>
+								</div>
+							</template>
+							<XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
+							<XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
+							<XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
+							<XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
+							<XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
+							<XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
+						</div>
+					</div>
+					<div v-if="!narrow" class="sub">
+						<XPhotos :key="user.id" :user="user"/>
+						<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
 					</div>
 				</div>
-				<div v-if="!narrow" class="sub">
-					<XPhotos :key="user.id" :user="user"/>
-					<XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/>
-				</div>
-			</div>
-		</MkSpacer>
-		<MkError v-else-if="error" @retry="fetch()"/>
-		<MkLoading v-else/>
-	</transition>
-</div>
+			</MkSpacer>
+			<MkError v-else-if="error" @retry="fetch()"/>
+			<MkLoading v-else/>
+		</transition>
+	</div>
+</MkStickyContainer>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, computed } from 'vue';
-import age from 's-age';
+<script lang="ts" setup>
+import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue';
+import calcAge from 's-age';
+import * as Acct from 'misskey-js/built/acct';
+import * as misskey from 'misskey-js';
 import XUserTimeline from './index.timeline.vue';
 import XNote from '@/components/note.vue';
 import MkFollowButton from '@/components/follow-button.vue';
@@ -134,164 +139,140 @@ import MkFolder from '@/components/ui/folder.vue';
 import MkRemoteCaution from '@/components/remote-caution.vue';
 import MkTab from '@/components/tab.vue';
 import MkInfo from '@/components/ui/info.vue';
-import * as Acct from 'misskey-js/built/acct';
 import { getScrollPosition } from '@/scripts/scroll';
 import { getUserMenu } from '@/scripts/get-user-menu';
 import number from '@/filters/number';
 import { userPage, acct as getAcct } from '@/filters/user';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
-import { MisskeyNavigator } from '@/scripts/navigate';
-
-export default defineComponent({
-	components: {
-		XUserTimeline,
-		XNote,
-		MkFollowButton,
-		MkContainer,
-		MkRemoteCaution,
-		MkFolder,
-		MkTab,
-		MkInfo,
-		XFollowList: defineAsyncComponent(() => import('./follow-list.vue')),
-		XReactions: defineAsyncComponent(() => import('./reactions.vue')),
-		XClips: defineAsyncComponent(() => import('./clips.vue')),
-		XPages: defineAsyncComponent(() => import('./pages.vue')),
-		XGallery: defineAsyncComponent(() => import('./gallery.vue')),
-		XPhotos: defineAsyncComponent(() => import('./index.photos.vue')),
-		XActivity: defineAsyncComponent(() => import('./index.activity.vue')),
-	},
-
-	props: {
-		acct: {
-			type: String,
-			required: true
-		},
-		page: {
-			type: String,
-			required: false,
-			default: 'index'
-		}
-	},
-
-	data() {
-		return {
-			[symbols.PAGE_INFO]: computed(() => this.user ? {
-				icon: 'fas fa-user',
-				title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`,
-				subtitle: `@${getAcct(this.user)}`,
-				userName: this.user,
-				avatar: this.user,
-				path: `/@${this.user.username}`,
-				share: {
-					title: this.user.name,
-				},
-				bg: 'var(--bg)',
-				tabs: [{
-					active: this.page === 'index',
-					title: this.$ts.overview,
-					icon: 'fas fa-home',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user)); },
-				}, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{
-					active: this.page === 'reactions',
-					title: this.$ts.reaction,
-					icon: 'fas fa-laugh',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/reactions'); },
-				}] : [], {
-					active: this.page === 'clips',
-					title: this.$ts.clips,
-					icon: 'fas fa-paperclip',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/clips'); },
-				}, {
-					active: this.page === 'pages',
-					title: this.$ts.pages,
-					icon: 'fas fa-file-alt',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/pages'); },
-				}, {
-					active: this.page === 'gallery',
-					title: this.$ts.gallery,
-					icon: 'fas fa-icons',
-					onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/gallery'); },
-				}],
-			} : null),
-			user: null,
-			error: null,
-			parallaxAnimationId: null,
-			narrow: null,
-			mkNav: new MisskeyNavigator(),
-		};
-	},
-
-	computed: {
-		style(): any {
-			if (this.user.bannerUrl == null) return {};
-			return {
-				backgroundImage: `url(${ this.user.bannerUrl })`
-			};
-		},
-
-		age(): number {
-			return age(this.user.birthday);
-		}
-	},
+import { useRouter } from '@/router';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { $i } from '@/account';
+
+const XFollowList = defineAsyncComponent(() => import('./follow-list.vue'));
+const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
+const XClips = defineAsyncComponent(() => import('./clips.vue'));
+const XPages = defineAsyncComponent(() => import('./pages.vue'));
+const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
+const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
+const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
+
+const props = withDefaults(defineProps<{
+	acct: string;
+	page?: string;
+}>(), {
+	page: 'index',
+});
 
-	watch: {
-		acct: 'fetch'
-	},
+const router = useRouter();
 
-	created() {
-		this.fetch();
-	},
+let user = $ref<null | misskey.entities.UserDetailed>(null);
+let error = $ref(null);
+let parallaxAnimationId = $ref<null | number>(null);
+let narrow = $ref<null | boolean>(null);
+let rootEl = $ref<null | HTMLElement>(null);
+let bannerEl = $ref<null | HTMLElement>(null);
 
-	mounted() {
-		window.requestAnimationFrame(this.parallaxLoop);
-		this.narrow = this.$el.clientWidth < 1000;
-	},
+const style = $computed(() => {
+	if (user?.bannerUrl == null) return {};
+	return {
+		backgroundImage: `url(${ user.bannerUrl })`,
+	};
+});
 
-	beforeUnmount() {
-		window.cancelAnimationFrame(this.parallaxAnimationId);
-	},
+const age = $computed(() => {
+	if (user == null) return null;
+	return calcAge(user.birthday);
+});
 
-	methods: {
-		getAcct,
+function fetchUser(): void {
+	if (props.acct == null) return;
+	user = null;
+	os.api('users/show', Acct.parse(props.acct)).then(u => {
+		user = u;
+	}).catch(err => {
+		error = err;
+	});
+}
 
-		fetch() {
-			if (this.acct == null) return;
-			this.user = null;
-			os.api('users/show', Acct.parse(this.acct)).then(user => {
-				this.user = user;
-			}).catch(err => {
-				this.error = err;
-			});
-		},
+watch(() => props.acct, fetchUser, {
+	immediate: true,
+});
 
-		menu(ev) {
-			os.popupMenu(getUserMenu(this.user), ev.currentTarget ?? ev.target);
-		},
+function menu(ev) {
+	os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target);
+}
 
-		parallaxLoop() {
-			this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop);
-			this.parallax();
-		},
+function parallaxLoop() {
+	parallaxAnimationId = window.requestAnimationFrame(parallaxLoop);
+	parallax();
+}
 
-		parallax() {
-			const banner = this.$refs.banner as any;
-			if (banner == null) return;
+function parallax() {
+	const banner = bannerEl as any;
+	if (banner == null) return;
 
-			const top = getScrollPosition(this.$el);
+	const top = getScrollPosition(rootEl);
 
-			if (top < 0) return;
+	if (top < 0) return;
 
-			const z = 1.75; // 奥行き(小さいほど奥)
-			const pos = -(top / z);
-			banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
-		},
+	const z = 1.75; // 奥行き(小さいほど奥)
+	const pos = -(top / z);
+	banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
+}
 
-		number,
+onMounted(() => {
+	window.requestAnimationFrame(parallaxLoop);
+	narrow = rootEl!.clientWidth < 1000;
+});
 
-		userPage
+onUnmounted(() => {
+	if (parallaxAnimationId) {
+		window.cancelAnimationFrame(parallaxAnimationId);
 	}
 });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => user ? [{
+	active: props.page === 'index',
+	title: i18n.ts.overview,
+	icon: 'fas fa-home',
+	onClick: () => { router.push('/@' + getAcct(user)); },
+}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{
+	active: props.page === 'reactions',
+	title: i18n.ts.reaction,
+	icon: 'fas fa-laugh',
+	onClick: () => { router.push('/@' + getAcct(user) + '/reactions'); },
+}] : [], {
+	active: props.page === 'clips',
+	title: i18n.ts.clips,
+	icon: 'fas fa-paperclip',
+	onClick: () => { router.push('/@' + getAcct(user) + '/clips'); },
+}, {
+	active: props.page === 'pages',
+	title: i18n.ts.pages,
+	icon: 'fas fa-file-alt',
+	onClick: () => { router.push('/@' + getAcct(user) + '/pages'); },
+}, {
+	active: props.page === 'gallery',
+	title: i18n.ts.gallery,
+	icon: 'fas fa-icons',
+	onClick: () => { router.push('/@' + getAcct(user) + '/gallery'); },
+}] : null);
+
+definePageMetadata(computed(() => user ? {
+	icon: 'fas fa-user',
+	title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+	subtitle: `@${getAcct(user)}`,
+	userName: user,
+	avatar: user,
+	path: `/@${user.username}`,
+	share: {
+		title: user.name,
+	},
+	bg: 'var(--bg)',
+} : null));
 </script>
 
 <style lang="scss" scoped>
diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue
index 98808229da..a1c3fc2abb 100644
--- a/packages/client/src/pages/welcome.vue
+++ b/packages/client/src/pages/welcome.vue
@@ -11,7 +11,7 @@ import XSetup from './welcome.setup.vue';
 import XEntrance from './welcome.entrance.a.vue';
 import { instanceName } from '@/config';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
+import { definePageMetadata } from '@/scripts/page-metadata';
 
 let meta = $ref(null);
 
@@ -19,10 +19,12 @@ os.api('meta', { detail: true }).then(res => {
 	meta = res;
 });
 
-defineExpose({
-	[symbols.PAGE_INFO]: computed(() => ({
-		title: instanceName,
-		icon: null,
-	})),
-});
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+	title: instanceName,
+	icon: null,
+})));
 </script>
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
index 7e16c35d2b..769d9cb2ac 100644
--- a/packages/client/src/router.ts
+++ b/packages/client/src/router.ts
@@ -1,147 +1,253 @@
-import { AsyncComponentLoader, defineAsyncComponent, markRaw } from 'vue';
-import { createRouter, createWebHistory } from 'vue-router';
-import { $i, iAmModerator } from './account';
+import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue';
+import { Router } from '@/nirax';
+import { $i, iAmModerator } from '@/account';
 import MkLoading from '@/pages/_loading_.vue';
 import MkError from '@/pages/_error_.vue';
-import MkTimeline from '@/pages/timeline.vue';
 import { ui } from '@/config';
 
-// pathに/が入るとrollupが解決してくれないので、() => import('*.vue')を指定すること
-const page = (path: string | AsyncComponentLoader<any>, uiName?: string) => defineAsyncComponent({
-	loader: typeof path === 'string' ? uiName ? () => import(`./ui/${ui}/pages/${path}.vue`) : () => import(`./pages/${path}.vue`) : path,
+const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
+	loader: loader,
 	loadingComponent: MkLoading,
 	errorComponent: MkError,
 });
 
-let indexScrollPos = 0;
+export const routes = [{
+	name: 'user',
+	path: '/@:acct/:page?',
+	component: page(() => import('./pages/user/index.vue')),
+}, {
+	path: '/@:initUser/pages/:initPageName/view-source',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+}, {
+	path: '/@:username/pages/:pageName',
+	component: page(() => import('./pages/page.vue')),
+}, {
+	name: 'note',
+	path: '/notes/:noteId',
+	component: page(() => import('./pages/note.vue')),
+}, {
+	path: '/clips/:clipId',
+	component: page(() => import('./pages/clip.vue')),
+}, {
+	path: '/user-info/:userId',
+	component: page(() => import('./pages/user-info.vue')),
+}, {
+	path: '/instance-info/:host',
+	component: page(() => import('./pages/instance-info.vue')),
+}, {
+	name: 'settings',
+	path: '/settings/:initialPage(*)?',
+	component: page(() => import('./pages/settings/index.vue')),
+}, {
+	path: '/reset-password/:token?',
+	component: page(() => import('./pages/reset-password.vue')),
+}, {
+	path: '/signup-complete/:code',
+	component: page(() => import('./pages/signup-complete.vue')),
+}, {
+	path: '/announcements',
+	component: page(() => import('./pages/announcements.vue')),
+}, {
+	path: '/about',
+	component: page(() => import('./pages/about.vue')),
+}, {
+	path: '/about-misskey',
+	component: page(() => import('./pages/about-misskey.vue')),
+}, {
+	path: '/featured',
+	component: page(() => import('./pages/featured.vue')),
+}, {
+	path: '/theme-editor',
+	component: page(() => import('./pages/theme-editor.vue')),
+}, {
+	path: '/explore/tags/:tag',
+	component: page(() => import('./pages/explore.vue')),
+}, {
+	path: '/explore',
+	component: page(() => import('./pages/explore.vue')),
+}, {
+	path: '/federation',
+	component: page(() => import('./pages/federation.vue')),
+}, {
+	path: '/emojis',
+	component: page(() => import('./pages/emojis.vue')),
+}, {
+	path: '/search',
+	component: page(() => import('./pages/search.vue')),
+	query: {
+		q: 'query',
+		channel: 'channel',
+	},
+}, {
+	path: '/authorize-follow',
+	component: page(() => import('./pages/follow.vue')),
+}, {
+	path: '/share',
+	component: page(() => import('./pages/share.vue')),
+}, {
+	path: '/api-console',
+	component: page(() => import('./pages/api-console.vue')),
+}, {
+	path: '/mfm-cheat-sheet',
+	component: page(() => import('./pages/mfm-cheat-sheet.vue')),
+}, {
+	path: '/scratchpad',
+	component: page(() => import('./pages/scratchpad.vue')),
+}, {
+	path: '/preview',
+	component: page(() => import('./pages/preview.vue')),
+}, {
+	path: '/auth/:token',
+	component: page(() => import('./pages/auth.vue')),
+}, {
+	path: '/miauth/:session',
+	component: page(() => import('./pages/miauth.vue')),
+	query: {
+		callback: 'callback',
+		name: 'name',
+		icon: 'icon',
+		permission: 'permission',
+	},
+}, {
+	path: '/tags/:tag',
+	component: page(() => import('./pages/tag.vue')),
+}, {
+	path: '/pages/new',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+}, {
+	path: '/pages/edit/:initPageId',
+	component: page(() => import('./pages/page-editor/page-editor.vue')),
+}, {
+	path: '/pages',
+	component: page(() => import('./pages/pages.vue')),
+}, {
+	path: '/gallery/:postId/edit',
+	component: page(() => import('./pages/gallery/edit.vue')),
+}, {
+	path: '/gallery/new',
+	component: page(() => import('./pages/gallery/edit.vue')),
+}, {
+	path: '/gallery/:postId',
+	component: page(() => import('./pages/gallery/post.vue')),
+}, {
+	path: '/gallery',
+	component: page(() => import('./pages/gallery/index.vue')),
+}, {
+	path: '/channels/:channelId/edit',
+	component: page(() => import('./pages/channel-editor.vue')),
+}, {
+	path: '/channels/new',
+	component: page(() => import('./pages/channel-editor.vue')),
+}, {
+	path: '/channels/:channelId',
+	component: page(() => import('./pages/channel.vue')),
+}, {
+	path: '/channels',
+	component: page(() => import('./pages/channels.vue')),
+}, {
+	path: '/admin/file/:fileId',
+	component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')),
+}, {
+	path: '/admin/:initialPage(*)?',
+	component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')),
+}, {
+	path: '/my/notifications',
+	component: page(() => import('./pages/notifications.vue')),
+}, {
+	path: '/my/favorites',
+	component: page(() => import('./pages/favorites.vue')),
+}, {
+	path: '/my/messages',
+	component: page(() => import('./pages/messages.vue')),
+}, {
+	path: '/my/mentions',
+	component: page(() => import('./pages/mentions.vue')),
+}, {
+	name: 'messaging',
+	path: '/my/messaging',
+	component: page(() => import('./pages/messaging/index.vue')),
+}, {
+	path: '/my/messaging/:userAcct',
+	component: page(() => import('./pages/messaging/messaging-room.vue')),
+}, {
+	path: '/my/messaging/group/:groupId',
+	component: page(() => import('./pages/messaging/messaging-room.vue')),
+}, {
+	path: '/my/drive/folder/:folder',
+	component: page(() => import('./pages/drive.vue')),
+}, {
+	path: '/my/drive',
+	component: page(() => import('./pages/drive.vue')),
+}, {
+	path: '/my/follow-requests',
+	component: page(() => import('./pages/follow-requests.vue')),
+}, {
+	path: '/my/lists/:listId',
+	component: page(() => import('./pages/my-lists/list.vue')),
+}, {
+	path: '/my/lists',
+	component: page(() => import('./pages/my-lists/index.vue')),
+}, {
+	path: '/my/clips',
+	component: page(() => import('./pages/my-clips/index.vue')),
+}, {
+	path: '/my/antennas/create',
+	component: page(() => import('./pages/my-antennas/create.vue')),
+}, {
+	path: '/my/antennas/:antennaId',
+	component: page(() => import('./pages/my-antennas/edit.vue')),
+}, {
+	path: '/my/antennas',
+	component: page(() => import('./pages/my-antennas/index.vue')),
+}, {
+	path: '/timeline/list/:listId',
+	component: page(() => import('./pages/user-list-timeline.vue')),
+}, {
+	path: '/timeline/antenna/:antennaId',
+	component: page(() => import('./pages/antenna-timeline.vue')),
+}, {
+	name: 'index',
+	path: '/',
+	component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')),
+	globalCacheKey: 'index',
+}, {
+	path: '/(*)',
+	component: page(() => import('./pages/not-found.vue')),
+}];
 
-const defaultRoutes = [
-	// NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる
-	{ path: '/', name: 'index', component: $i ? MkTimeline : page('welcome') },
-	{ path: '/@:acct/:page?', name: 'user', component: page(() => import('./pages/user/index.vue')), props: route => ({ acct: route.params.acct, page: route.params.page || 'index' }) },
-	{ path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) },
-	{ path: '/@:user/pages/:pageName/view-source', component: page(() => import('./pages/page-editor/page-editor.vue')), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) },
-	{ path: '/settings/:page(.*)?', name: 'settings', component: page(() => import('./pages/settings/index.vue')), props: route => ({ initialPage: route.params.page || null }) },
-	{ path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) },
-	{ path: '/signup-complete/:code', component: page('signup-complete'), props: route => ({ code: route.params.code }) },
-	{ path: '/announcements', component: page('announcements') },
-	{ path: '/about', component: page('about') },
-	{ path: '/about-misskey', component: page('about-misskey') },
-	{ path: '/featured', component: page('featured') },
-	{ path: '/theme-editor', component: page('theme-editor') },
-	{ path: '/advanced-theme-editor', component: page('advanced-theme-editor') },
-	{ path: '/explore', component: page('explore') },
-	{ path: '/explore/tags/:tag', props: true, component: page('explore') },
-	{ path: '/federation', component: page('federation') },
-	{ path: '/emojis', component: page('emojis') },
-	{ path: '/search', component: page('search'), props: route => ({ query: route.query.q, channel: route.query.channel }) },
-	{ path: '/pages', name: 'pages', component: page('pages') },
-	{ path: '/pages/new', component: page(() => import('./pages/page-editor/page-editor.vue')) },
-	{ path: '/pages/edit/:pageId', component: page(() => import('./pages/page-editor/page-editor.vue')), props: route => ({ initPageId: route.params.pageId }) },
-	{ path: '/gallery', component: page(() => import('./pages/gallery/index.vue')) },
-	{ path: '/gallery/new', component: page(() => import('./pages/gallery/edit.vue')) },
-	{ path: '/gallery/:postId/edit', component: page(() => import('./pages/gallery/edit.vue')), props: route => ({ postId: route.params.postId }) },
-	{ path: '/gallery/:postId', component: page(() => import('./pages/gallery/post.vue')), props: route => ({ postId: route.params.postId }) },
-	{ path: '/channels', component: page('channels') },
-	{ path: '/channels/new', component: page('channel-editor') },
-	{ path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
-	{ path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) },
-	{ path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) },
-	{ path: '/timeline/list/:listId', component: page('user-list-timeline'), props: route => ({ listId: route.params.listId }) },
-	{ path: '/timeline/antenna/:antennaId', component: page('antenna-timeline'), props: route => ({ antennaId: route.params.antennaId }) },
-	{ path: '/my/notifications', component: page('notifications') },
-	{ path: '/my/favorites', component: page('favorites') },
-	{ path: '/my/messages', component: page('messages') },
-	{ path: '/my/mentions', component: page('mentions') },
-	{ path: '/my/messaging', name: 'messaging', component: page(() => import('./pages/messaging/index.vue')) },
-	{ path: '/my/messaging/:user', component: page(() => import('./pages/messaging/messaging-room.vue')), props: route => ({ userAcct: route.params.user }) },
-	{ path: '/my/messaging/group/:group', component: page(() => import('./pages/messaging/messaging-room.vue')), props: route => ({ groupId: route.params.group }) },
-	{ path: '/my/drive', name: 'drive', component: page('drive') },
-	{ path: '/my/drive/folder/:folder', component: page('drive') },
-	{ path: '/my/follow-requests', component: page('follow-requests') },
-	{ path: '/my/lists', component: page(() => import('./pages/my-lists/index.vue')) },
-	{ path: '/my/lists/:list', component: page(() => import('./pages/my-lists/list.vue')) },
-	{ path: '/my/groups', component: page(() => import('./pages/my-groups/index.vue')) },
-	{ path: '/my/groups/:group', component: page(() => import('./pages/my-groups/group.vue')), props: route => ({ groupId: route.params.group }) },
-	{ path: '/my/antennas', component: page(() => import('./pages/my-antennas/index.vue')) },
-	{ path: '/my/antennas/create', component: page(() => import('./pages/my-antennas/create.vue')) },
-	{ path: '/my/antennas/:antennaId', component: page(() => import('./pages/my-antennas/edit.vue')), props: true },
-	{ path: '/my/clips', component: page(() => import('./pages/my-clips/index.vue')) },
-	{ path: '/scratchpad', component: page('scratchpad') },
-	{ path: '/admin/:page(.*)?', component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page('not-found'), props: route => ({ initialPage: route.params.page || null }) },
-	{ path: '/admin', component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page('not-found') },
-	{ path: '/admin-file/:fileId', component: page('admin-file'), props: route => ({ fileId: route.params.fileId }) },
-	{ path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) },
-	{ path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) },
-	{ path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) },
-	{ path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) },
-	{ path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') },
-	{ path: '/api-console', component: page('api-console') },
-	{ path: '/preview', component: page('preview') },
-	{ path: '/test', component: page('test') },
-	{ path: '/auth/:token', component: page('auth') },
-	{ path: '/miauth/:session', component: page('miauth') },
-	{ path: '/authorize-follow', component: page('follow') },
-	{ path: '/share', component: page('share') },
-	{ path: '/:catchAll(.*)', component: page('not-found') },
-];
+export const mainRouter = new Router(routes, location.pathname + location.search);
 
-const chatRoutes = [
-	{ path: '/timeline', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) },
-	{ path: '/timeline/home', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) },
-	{ path: '/timeline/local', component: page('timeline', 'chat'), props: route => ({ src: 'local' }) },
-	{ path: '/timeline/social', component: page('timeline', 'chat'), props: route => ({ src: 'social' }) },
-	{ path: '/timeline/global', component: page('timeline', 'chat'), props: route => ({ src: 'global' }) },
-	{ path: '/channels/:channelId', component: page('channel', 'chat'), props: route => ({ channelId: route.params.channelId }) },
-];
+window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href);
 
-function margeRoutes(routes: any[]) {
-	const result = defaultRoutes;
-	for (const route of routes) {
-		const found = result.findIndex(x => x.path === route.path);
-		if (found > -1) {
-			result[found] = route;
-		} else {
-			result.unshift(route);
-		}
-	}
-	return result;
-}
+// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする
+// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも
 
-export const router = createRouter({
-	history: createWebHistory(),
-	routes: margeRoutes(ui === 'chat' ? chatRoutes : []),
-	// なんかHacky
-	// 通常の使い方をすると scroll メソッドの behavior を設定できないため、自前で window.scroll するようにする
-	scrollBehavior(to) {
-		window._scroll = () => { // さらにHacky
-			if (to.name === 'index') {
-				window.scroll({ top: indexScrollPos, behavior: 'instant' });
-				const i = window.setInterval(() => {
-					window.scroll({ top: indexScrollPos, behavior: 'instant' });
-				}, 10);
-				window.setTimeout(() => {
-					window.clearInterval(i);
-				}, 500);
-			} else {
-				window.scroll({ top: 0, behavior: 'instant' });
-			}
-		};
-	},
-});
+const scrollPosStore = new Map<string, number>();
+
+window.setInterval(() => {
+	scrollPosStore.set(window.history.state?.key, window.scrollY);
+}, 1000);
 
-router.afterEach((to, from) => {
-	if (from.name === 'index') {
-		indexScrollPos = window.scrollY;
+mainRouter.addListener('push', ctx => {
+	window.history.pushState({ key: ctx.key }, '', ctx.path);
+	const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
+	window.scroll({ top: scrollPos, behavior: 'instant' });
+	if (scrollPos !== 0) {
+		window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
+			window.scroll({ top: scrollPos, behavior: 'instant' });
+		}, 1000);
 	}
 });
 
-export function resolve(path: string) {
-	const resolved = router.resolve(path);
-	const route = resolved.matched[0];
-	return {
-		component: markRaw(route.components.default),
-		// TODO: route.propsには関数以外も入る可能性があるのでよしなにハンドリングする
-		props: route.props.default ? route.props.default(resolved) : resolved.params,
-	};
+window.addEventListener('popstate', (event) => {
+	mainRouter.change(location.pathname + location.search, event.state?.key);
+	const scrollPos = scrollPosStore.get(event.state?.key) ?? 0;
+	window.scroll({ top: scrollPos, behavior: 'instant' });
+	window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
+		window.scroll({ top: scrollPos, behavior: 'instant' });
+	}, 1000);
+});
+
+export function useRouter(): Router {
+	return inject<Router | null>('router', null) ?? mainRouter;
 }
diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts
index 091338efd6..25bcd90e9f 100644
--- a/packages/client/src/scripts/get-user-menu.ts
+++ b/packages/client/src/scripts/get-user-menu.ts
@@ -1,12 +1,12 @@
+import * as Acct from 'misskey-js/built/acct';
+import { defineAsyncComponent } from 'vue';
 import { i18n } from '@/i18n';
 import copyToClipboard from '@/scripts/copy-to-clipboard';
 import { host } from '@/config';
-import * as Acct from 'misskey-js/built/acct';
 import * as os from '@/os';
 import { userActions } from '@/store';
-import { router } from '@/router';
 import { $i, iAmModerator } from '@/account';
-import { defineAsyncComponent } from 'vue';
+import { mainRouter } from '@/router';
 
 export function getUserMenu(user) {
 	const meId = $i ? $i.id : null;
@@ -17,20 +17,20 @@ export function getUserMenu(user) {
 		if (lists.length === 0) {
 			os.alert({
 				type: 'error',
-				text: i18n.ts.youHaveNoLists
+				text: i18n.ts.youHaveNoLists,
 			});
 			return;
 		}
 		const { canceled, result: listId } = await os.select({
 			title: t,
 			items: lists.map(list => ({
-				value: list.id, text: list.name
-			}))
+				value: list.id, text: list.name,
+			})),
 		});
 		if (canceled) return;
 		os.apiWithDialog('users/lists/push', {
 			listId: listId,
-			userId: user.id
+			userId: user.id,
 		});
 	}
 
@@ -39,20 +39,20 @@ export function getUserMenu(user) {
 		if (groups.length === 0) {
 			os.alert({
 				type: 'error',
-				text: i18n.ts.youHaveNoGroups
+				text: i18n.ts.youHaveNoGroups,
 			});
 			return;
 		}
 		const { canceled, result: groupId } = await os.select({
 			title: i18n.ts.group,
 			items: groups.map(group => ({
-				value: group.id, text: group.name
-			}))
+				value: group.id, text: group.name,
+			})),
 		});
 		if (canceled) return;
 		os.apiWithDialog('users/groups/invite', {
 			groupId: groupId,
-			userId: user.id
+			userId: user.id,
 		});
 	}
 
@@ -101,7 +101,7 @@ export function getUserMenu(user) {
 		if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return;
 
 		os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isBlocking = !user.isBlocking;
 		});
@@ -111,7 +111,7 @@ export function getUserMenu(user) {
 		if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return;
 
 		os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isSilenced = !user.isSilenced;
 		});
@@ -121,7 +121,7 @@ export function getUserMenu(user) {
 		if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
 
 		os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isSuspended = !user.isSuspended;
 		});
@@ -145,7 +145,7 @@ export function getUserMenu(user) {
 
 	async function invalidateFollow() {
 		os.apiWithDialog('following/invalidate', {
-			userId: user.id
+			userId: user.id,
 		}).then(() => {
 			user.isFollowed = !user.isFollowed;
 		});
@@ -156,19 +156,19 @@ export function getUserMenu(user) {
 		text: i18n.ts.copyUsername,
 		action: () => {
 			copyToClipboard(`@${user.username}@${user.host || host}`);
-		}
+		},
 	}, {
 		icon: 'fas fa-info-circle',
 		text: i18n.ts.info,
 		action: () => {
 			os.pageWindow(`/user-info/${user.id}`);
-		}
+		},
 	}, {
 		icon: 'fas fa-envelope',
 		text: i18n.ts.sendMessage,
 		action: () => {
 			os.post({ specified: user });
-		}
+		},
 	}, meId !== user.id ? {
 		type: 'link',
 		icon: 'fas fa-comments',
@@ -177,47 +177,47 @@ export function getUserMenu(user) {
 	} : undefined, null, {
 		icon: 'fas fa-list-ul',
 		text: i18n.ts.addToList,
-		action: pushList
+		action: pushList,
 	}, meId !== user.id ? {
 		icon: 'fas fa-users',
 		text: i18n.ts.inviteToGroup,
-		action: inviteGroup
+		action: inviteGroup,
 	} : undefined] as any;
 
 	if ($i && meId !== user.id) {
 		menu = menu.concat([null, {
 			icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash',
 			text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
-			action: toggleMute
+			action: toggleMute,
 		}, {
 			icon: 'fas fa-ban',
 			text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
-			action: toggleBlock
+			action: toggleBlock,
 		}]);
 
 		if (user.isFollowed) {
 			menu = menu.concat([{
 				icon: 'fas fa-unlink',
 				text: i18n.ts.breakFollow,
-				action: invalidateFollow
+				action: invalidateFollow,
 			}]);
 		}
 
 		menu = menu.concat([null, {
 			icon: 'fas fa-exclamation-circle',
 			text: i18n.ts.reportAbuse,
-			action: reportAbuse
+			action: reportAbuse,
 		}]);
 
 		if (iAmModerator) {
 			menu = menu.concat([null, {
 				icon: 'fas fa-microphone-slash',
 				text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence,
-				action: toggleSilence
+				action: toggleSilence,
 			}, {
 				icon: 'fas fa-snowflake',
 				text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend,
-				action: toggleSuspend
+				action: toggleSuspend,
 			}]);
 		}
 	}
@@ -227,8 +227,8 @@ export function getUserMenu(user) {
 			icon: 'fas fa-pencil-alt',
 			text: i18n.ts.editProfile,
 			action: () => {
-				router.push('/settings/profile');
-			}
+				mainRouter.push('/settings/profile');
+			},
 		}]);
 	}
 
@@ -238,7 +238,7 @@ export function getUserMenu(user) {
 			text: action.title,
 			action: () => {
 				action.handler(user);
-			}
+			},
 		}))]);
 	}
 
diff --git a/packages/client/src/scripts/navigate.ts b/packages/client/src/scripts/navigate.ts
deleted file mode 100644
index 08b891ec5b..0000000000
--- a/packages/client/src/scripts/navigate.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { inject } from 'vue';
-import { router } from '@/router';
-import { defaultStore } from '@/store';
-
-export type Navigate = (path: string, record?: boolean) => void;
-
-export class MisskeyNavigator {
-	public readonly navHook: Navigate | null = null;
-	public readonly sideViewHook: Navigate | null = null;
-
-	// It should be constructed during vue creating in order for inject function to work
-	constructor() {
-		this.navHook = inject<Navigate | null>('navHook', null);
-		this.sideViewHook = inject<Navigate | null>('sideViewHook', null);
-	}
-
-	// Use this method instead of router.push()
-	public push(path: string, record = true) {
-		if (this.navHook) {
-			this.navHook(path, record);
-		} else {
-			if (defaultStore.state.defaultSideView && this.sideViewHook && path !== '/') {
-				return this.sideViewHook(path, record);
-			}
-	
-			if (router.currentRoute.value.path === path) {
-				window.scroll({ top: 0, behavior: 'smooth' });
-			} else {
-				if (record) router.push(path);
-				else router.replace(path);
-			}
-		}
-	}
-}
diff --git a/packages/client/src/scripts/page-metadata.ts b/packages/client/src/scripts/page-metadata.ts
new file mode 100644
index 0000000000..0db8369f9d
--- /dev/null
+++ b/packages/client/src/scripts/page-metadata.ts
@@ -0,0 +1,41 @@
+import * as misskey from 'misskey-js';
+import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue';
+
+export const setPageMetadata = Symbol('setPageMetadata');
+export const pageMetadataProvider = Symbol('pageMetadataProvider');
+
+export type PageMetadata = {
+	title: string;
+	subtitle?: string;
+	icon?: string | null;
+	avatar?: misskey.entities.User | null;
+	userName?: misskey.entities.User | null;
+	bg?: string;
+};
+
+export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
+	const _metadata = isRef(metadata) ? metadata : ref(metadata);
+
+	provide(pageMetadataProvider, _metadata);
+
+	const set = inject(setPageMetadata) as any;
+	if (set) {
+		set(_metadata);
+
+		onMounted(() => {
+			set(_metadata);
+		});
+
+		onActivated(() => {
+			set(_metadata);
+		});
+	}
+}
+
+export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void {
+	provide(setPageMetadata, callback);
+}
+
+export function injectPageMetadata(): PageMetadata | undefined {
+	return inject(pageMetadataProvider);
+}
diff --git a/packages/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts
index 0aedee9c98..64914d3d65 100644
--- a/packages/client/src/scripts/search.ts
+++ b/packages/client/src/scripts/search.ts
@@ -1,6 +1,6 @@
 import * as os from '@/os';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
 
 export async function search() {
 	const { canceled, result: query } = await os.inputText({
@@ -11,12 +11,12 @@ export async function search() {
 	const q = query.trim();
 
 	if (q.startsWith('@') && !q.includes(' ')) {
-		router.push(`/${q}`);
+		mainRouter.push(`/${q}`);
 		return;
 	}
 
 	if (q.startsWith('#')) {
-		router.push(`/tags/${encodeURIComponent(q.substr(1))}`);
+		mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`);
 		return;
 	}
 
@@ -36,14 +36,14 @@ export async function search() {
 		//v.$root.$emit('warp', date);
 		os.alert({
 			icon: 'fas fa-history',
-			iconOnly: true, autoClose: true
+			iconOnly: true, autoClose: true,
 		});
 		return;
 	}
 
 	if (q.startsWith('https://')) {
 		const promise = os.api('ap/show', {
-			uri: q
+			uri: q,
 		});
 
 		os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
@@ -51,13 +51,13 @@ export async function search() {
 		const res = await promise;
 
 		if (res.type === 'User') {
-			router.push(`/@${res.object.username}@${res.object.host}`);
+			mainRouter.push(`/@${res.object.username}@${res.object.host}`);
 		} else if (res.type === 'Note') {
-			router.push(`/notes/${res.object.id}`);
+			mainRouter.push(`/notes/${res.object.id}`);
 		}
 
 		return;
 	}
 
-	router.push(`/search?q=${encodeURIComponent(q)}`);
+	mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
 }
diff --git a/packages/client/src/scripts/use-leave-guard.ts b/packages/client/src/scripts/use-leave-guard.ts
index 33eea6b522..379a7e577c 100644
--- a/packages/client/src/scripts/use-leave-guard.ts
+++ b/packages/client/src/scripts/use-leave-guard.ts
@@ -1,5 +1,4 @@
 import { inject, onUnmounted, Ref } from 'vue';
-import { onBeforeRouteLeave } from 'vue-router';
 import { i18n } from '@/i18n';
 import * as os from '@/os';
 
diff --git a/packages/client/src/symbols.ts b/packages/client/src/symbols.ts
deleted file mode 100644
index 6913f29c28..0000000000
--- a/packages/client/src/symbols.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const PAGE_INFO = Symbol('Page info');
diff --git a/packages/client/src/ui/_common_/sw-inject.ts b/packages/client/src/ui/_common_/sw-inject.ts
index 371f80ca15..8676d2d48d 100644
--- a/packages/client/src/ui/_common_/sw-inject.ts
+++ b/packages/client/src/ui/_common_/sw-inject.ts
@@ -3,12 +3,9 @@ import { post } from '@/os';
 import { $i, login } from '@/account';
 import { defaultStore } from '@/store';
 import { getAccountFromId } from '@/scripts/get-account-from-id';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
 
 export function swInject() {
-	const navHook = inject('navHook', null);
-	const sideViewHook = inject('sideViewHook', null);
-
 	navigator.serviceWorker.addEventListener('message', ev => {
 		if (_DEV_) {
 			console.log('sw msg', ev.data);
@@ -27,16 +24,10 @@ export function swInject() {
 			case 'post':
 				return post(ev.data.options);
 			case 'push':
-				if (router.currentRoute.value.path === ev.data.url) {
+				if (mainRouter.currentRoute.value.path === ev.data.url) {
 					return window.scroll({ top: 0, behavior: 'smooth' });
 				}
-				if (navHook) {
-					return navHook(ev.data.url);
-				}
-				if (sideViewHook && defaultStore.state.defaultSideView && ev.data.url !== '/') {
-					return sideViewHook(ev.data.url);
-				}
-				return router.push(ev.data.url);
+				return mainRouter.push(ev.data.url);
 			default:
 				return;
 		}
diff --git a/packages/client/src/ui/classic.side.vue b/packages/client/src/ui/classic.side.vue
deleted file mode 100644
index 6c2329194e..0000000000
--- a/packages/client/src/ui/classic.side.vue
+++ /dev/null
@@ -1,148 +0,0 @@
-<template>
-<div v-if="component" class="qvzfzxam _narrow_">
-	<div class="container">
-		<header class="header" @contextmenu.prevent.stop="onContextmenu">
-			<button v-if="history.length > 0" class="_button" @click="back()"><i class="fas fa-chevron-left"></i></button>
-			<button v-else class="_button" style="pointer-events: none;"><!-- マージンのバランスを取るためのダミー --></button>
-			<span class="title" v-text="pageInfo?.title" />
-			<button class="_button" @click="close()"><i class="fas fa-times"></i></button>
-		</header>
-		<MkHeader class="pageHeader" :info="pageInfo"/>
-		<component :is="component" v-bind="props" :ref="changePage"/>
-	</div>
-</div>
-</template>
-
-<script lang="ts" setup>
-import { provide } from 'vue';
-import * as os from '@/os';
-import copyToClipboard from '@/scripts/copy-to-clipboard';
-import { resolve, router } from '@/router';
-import { url as root } from '@/config';
-import * as symbols from '@/symbols';
-import { i18n } from '@/i18n';
-
-provide('navHook', navigate);
-
-let path: string | null = $ref(null);
-let component: ReturnType<typeof resolve>['component'] | null = $ref(null);
-let props: any | null = $ref(null);
-let pageInfo: any | null = $ref(null);
-let history: string[] = $ref([]);
-
-let url = $computed(() => `${root}${path}`);
-
-function changePage(page) {
-	if (page == null) return;
-	if (page[symbols.PAGE_INFO]) {
-		pageInfo = page[symbols.PAGE_INFO];
-	}
-}
-
-function navigate(_path: string, record = true) {
-	if (record && path) history.push($$(path).value);
-	path = _path;
-	const resolved = resolve(path);
-	component = resolved.component;
-	props = resolved.props;
-}
-
-function back() {
-	const prev = history.pop();
-	if (prev) navigate(prev, false);
-}
-
-function close() {
-	path = null;
-	component = null;
-	props = {};
-}
-
-function onContextmenu(ev: MouseEvent) {
-	os.contextMenu([{
-		type: 'label',
-		text: path || '',
-	}, {
-		icon: 'fas fa-expand-alt',
-		text: i18n.ts.showInPage,
-		action: () => {
-			if (path) router.push(path);
-			close();
-		}
-	}, {
-		icon: 'fas fa-window-maximize',
-		text: i18n.ts.openInWindow,
-		action: () => {
-			if (path) os.pageWindow(path);
-			close();
-		}
-	}, null, {
-		icon: 'fas fa-external-link-alt',
-		text: i18n.ts.openInNewTab,
-		action: () => {
-			window.open(url, '_blank');
-			close();
-		}
-	}, {
-		icon: 'fas fa-link',
-		text: i18n.ts.copyLink,
-		action: () => {
-			copyToClipboard(url);
-		}
-	}], ev);
-}
-
-defineExpose({
-	navigate,
-	back,
-	close,
-});
-</script>
-
-<style lang="scss" scoped>
-.qvzfzxam {
-	$header-height: 58px; // TODO: どこかに集約したい
-
-	--root-margin: 16px;
-	--margin: var(--marginHalf);
-
-	> .container {
-		position: fixed;
-		width: 370px;
-		height: 100vh;
-		overflow: auto;
-		box-sizing: border-box;
-
-		> .header {
-			display: flex;
-			position: sticky;
-			z-index: 1000;
-			top: 0;
-			height: $header-height;
-			width: 100%;
-			line-height: $header-height;
-			text-align: center;
-			font-weight: bold;
-			//background-color: var(--panel);
-			-webkit-backdrop-filter: var(--blur, blur(32px));
-			backdrop-filter: var(--blur, blur(32px));
-			background-color: var(--header);
-
-			> ._button {
-				height: $header-height;
-				width: $header-height;
-
-				&:hover {
-					color: var(--fgHighlighted);
-				}
-			}
-
-			> .title {
-				flex: 1;
-				position: relative;
-			}
-		}
-	}
-}
-</style>
-
diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue
index c61cbc433e..f5fa8f336a 100644
--- a/packages/client/src/ui/classic.vue
+++ b/packages/client/src/ui/classic.vue
@@ -7,31 +7,23 @@
 			<XSidebar/>
 		</div>
 		<div v-else ref="widgetsLeft" class="widgets left">
-			<XWidgets :place="'left'" @mounted="attachSticky('widgetsLeft')"/>
+			<XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/>
 		</div>
 
-		<main class="main" :style="{ background: pageInfo?.bg }" @contextmenu.stop="onContextmenu">
+		<main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
 			<div class="content">
-				<MkStickyContainer>
-					<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-					<router-view v-slot="{ Component }">
-						<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-							<keep-alive :include="['MkTimelinePage']">
-								<component :is="Component" :ref="changePage"/>
-							</keep-alive>
-						</transition>
-					</router-view>
-				</MkStickyContainer>
+				<RouterView/>
 			</div>
 		</main>
 
 		<div v-if="isDesktop" ref="widgetsRight" class="widgets right">
-			<XWidgets :place="null" @mounted="attachSticky('widgetsRight')"/>
+			<XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/>
 		</div>
 	</div>
 
 	<transition :name="$store.state.animation ? 'tray-back' : ''">
-		<div v-if="widgetsShowing"
+		<div
+			v-if="widgetsShowing"
 			class="tray-back _modalBg"
 			@click="widgetsShowing = false"
 			@touchstart.passive="widgetsShowing = false"
@@ -48,157 +40,134 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent, markRaw } from 'vue';
-import { instanceName } from '@/config';
-import { StickySidebar } from '@/scripts/sticky-sidebar';
+<script lang="ts" setup>
+import { defineAsyncComponent, markRaw, ComputedRef, ref, onMounted, provide } from 'vue';
 import XSidebar from './classic.sidebar.vue';
 import XCommon from './_common_/common.vue';
+import { instanceName } from '@/config';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
 import * as os from '@/os';
 import { menuDef } from '@/menu';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue'));
+const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue'));
 
 const DESKTOP_THRESHOLD = 1100;
 
-export default defineComponent({
-	components: {
-		XCommon,
-		XSidebar,
-		XHeaderMenu: defineAsyncComponent(() => import('./classic.header.vue')),
-		XWidgets: defineAsyncComponent(() => import('./classic.widgets.vue')),
-	},
-
-	provide() {
-		return {
-			shouldHeaderThin: this.showMenuOnTop,
-			shouldSpacerMin: true,
-		};
-	},
-
-	data() {
-		return {
-			pageInfo: null,
-			menuDef: menuDef,
-			globalHeaderHeight: 0,
-			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
-			widgetsShowing: false,
-			fullView: false,
-			wallpaper: localStorage.getItem('wallpaper') != null,
-		};
-	},
-
-	computed: {
-		showMenuOnTop(): boolean {
-			return this.$store.state.menuDisplay === 'top';
-		}
-	},
-
-	created() {
-		if (window.innerWidth < 1024) {
-			localStorage.setItem('ui', 'default');
-			location.reload();
-		}
-
-		document.documentElement.style.overflowY = 'scroll';
-
-		if (this.$store.state.widgets.length === 0) {
-			this.$store.set('widgets', [{
-				name: 'calendar',
-				id: 'a', place: null, data: {}
-			}, {
-				name: 'notifications',
-				id: 'b', place: null, data: {}
-			}, {
-				name: 'trends',
-				id: 'c', place: null, data: {}
-			}]);
-		}
-	},
+const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
+
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+const widgetsShowing = $ref(false);
+const fullView = $ref(false);
+let globalHeaderHeight = $ref(0);
+const wallpaper = localStorage.getItem('wallpaper') != null;
+const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top');
+let live2d = $ref<HTMLIFrameElement>();
+let widgetsLeft = $ref();
+let widgetsRight = $ref();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
+provide('shouldHeaderThin', showMenuOnTop);
+provide('shouldSpacerMin', true);
+
+function attachSticky(el) {
+	const sticky = new StickySidebar(el, defaultStore.state.menuDisplay === 'top' ? 0 : 16, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
+	window.addEventListener('scroll', () => {
+		sticky.calc(window.scrollY);
+	}, { passive: true });
+}
 
-	mounted() {
-		window.addEventListener('resize', () => {
-			this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
-		}, { passive: true });
+function top() {
+	window.scroll({ top: 0, behavior: 'smooth' });
+}
 
-		if (this.$store.state.aiChanMode) {
-			const iframeRect = this.$refs.live2d.getBoundingClientRect();
-			window.addEventListener('mousemove', ev => {
-				this.$refs.live2d.contentWindow.postMessage({
-					type: 'moveCursor',
-					body: {
-						x: ev.clientX - iframeRect.left,
-						y: ev.clientY - iframeRect.top,
-					}
-				}, '*');
-			}, { passive: true });
-			window.addEventListener('touchmove', ev => {
-				this.$refs.live2d.contentWindow.postMessage({
-					type: 'moveCursor',
-					body: {
-						x: ev.touches[0].clientX - iframeRect.left,
-						y: ev.touches[0].clientY - iframeRect.top,
-					}
-				}, '*');
-			}, { passive: true });
+function onContextmenu(ev: MouseEvent) {
+	const isLink = (el: HTMLElement) => {
+		if (el.tagName === 'A') return true;
+		if (el.parentElement) {
+			return isLink(el.parentElement);
 		}
-	},
-
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-				document.title = `${this.pageInfo.title} | ${instanceName}`;
-			}
+	};
+	if (isLink(ev.target)) return;
+	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
+	if (window.getSelection().toString() !== '') return;
+	const path = mainRouter.getCurrentPath();
+	os.contextMenu([{
+		type: 'label',
+		text: path,
+	}, {
+		icon: fullView ? 'fas fa-compress' : 'fas fa-expand',
+		text: fullView ? i18n.ts.quitFullView : i18n.ts.fullView,
+		action: () => {
+			fullView = !fullView;
 		},
-
-		attachSticky(ref) {
-			const sticky = new StickySidebar(this.$refs[ref], this.$store.state.menuDisplay === 'top' ? 0 : 16, this.$store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
-			window.addEventListener('scroll', () => {
-				sticky.calc(window.scrollY);
-			}, { passive: true });
+	}, {
+		icon: 'fas fa-window-maximize',
+		text: i18n.ts.openInWindow,
+		action: () => {
+			os.pageWindow(path);
 		},
+	}], ev);
+}
 
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
+function onAiClick(ev) {
+	//if (this.live2d) this.live2d.click(ev);
+}
 
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
+if (window.innerWidth < 1024) {
+	localStorage.setItem('ui', 'default');
+	location.reload();
+}
 
-		onContextmenu(ev: MouseEvent) {
-			const isLink = (el: HTMLElement) => {
-				if (el.tagName === 'A') return true;
-				if (el.parentElement) {
-					return isLink(el.parentElement);
-				}
-			};
-			if (isLink(ev.target)) return;
-			if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
-			if (window.getSelection().toString() !== '') return;
-			const path = this.$route.path;
-			os.contextMenu([{
-				type: 'label',
-				text: path,
-			}, {
-				icon: this.fullView ? 'fas fa-compress' : 'fas fa-expand',
-				text: this.fullView ? this.$ts.quitFullView : this.$ts.fullView,
-				action: () => {
-					this.fullView = !this.fullView;
-				}
-			}, {
-				icon: 'fas fa-window-maximize',
-				text: this.$ts.openInWindow,
-				action: () => {
-					os.pageWindow(path);
-				}
-			}], ev);
-		},
+document.documentElement.style.overflowY = 'scroll';
+
+if (defaultStore.state.widgets.length === 0) {
+	defaultStore.set('widgets', [{
+		name: 'calendar',
+		id: 'a', place: null, data: {},
+	}, {
+		name: 'notifications',
+		id: 'b', place: null, data: {},
+	}, {
+		name: 'trends',
+		id: 'c', place: null, data: {},
+	}]);
+}
 
-		onAiClick(ev) {
-			//if (this.live2d) this.live2d.click(ev);
-		}
+onMounted(() => {
+	window.addEventListener('resize', () => {
+		isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
+	}, { passive: true });
+
+	if (defaultStore.state.aiChanMode) {
+		const iframeRect = live2d.getBoundingClientRect();
+		window.addEventListener('mousemove', ev => {
+			live2d.contentWindow.postMessage({
+				type: 'moveCursor',
+				body: {
+					x: ev.clientX - iframeRect.left,
+					y: ev.clientY - iframeRect.top,
+				},
+			}, '*');
+		}, { passive: true });
+		window.addEventListener('touchmove', ev => {
+			live2d.contentWindow.postMessage({
+				type: 'moveCursor',
+				body: {
+					x: ev.touches[0].clientX - iframeRect.left,
+					y: ev.touches[0].clientY - iframeRect.top,
+				},
+			}, '*');
+		}, { passive: true });
 	}
 });
 </script>
diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue
index e538a93f06..7433264794 100644
--- a/packages/client/src/ui/deck.vue
+++ b/packages/client/src/ui/deck.vue
@@ -1,18 +1,21 @@
 <template>
-<div class="mk-deck" :class="[{ isMobile }, `${deckStore.reactiveState.columnAlign.value}`]" :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }"
+<div
+	class="mk-deck" :class="[{ isMobile }, `${deckStore.reactiveState.columnAlign.value}`]" :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }"
 	@contextmenu.self.prevent="onContextmenu"
 >
 	<XSidebar v-if="!isMobile"/>
 
 	<template v-for="ids in layout">
 		<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
-		<section v-if="ids.length > 1"
+		<section
+			v-if="ids.length > 1"
 			class="folder column"
 			:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
 		>
 			<DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
 		</section>
-		<DeckColumnCore v-else
+		<DeckColumnCore
+			v-else
 			:ref="ids[0]"
 			:key="ids[0]"
 			class="column"
@@ -25,13 +28,14 @@
 
 	<div v-if="isMobile" class="buttons">
 		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
-		<button class="button home _button" @click="$router.push('/')"><i class="fas fa-home"></i></button>
-		<button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
+		<button class="button home _button" @click="mainRouter.push('/')"><i class="fas fa-home"></i></button>
+		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
 		<button class="button post _button" @click="os.post()"><i class="fas fa-pencil-alt"></i></button>
 	</div>
 
 	<transition :name="$store.state.animation ? 'menu-back' : ''">
-		<div v-if="drawerMenuShowing"
+		<div
+			v-if="drawerMenuShowing"
 			class="menu-back _modalBg"
 			@click="drawerMenuShowing = false"
 			@touchstart.passive="drawerMenuShowing = false"
@@ -49,17 +53,17 @@
 <script lang="ts" setup>
 import { computed, provide, ref, watch } from 'vue';
 import { v4 as uuid } from 'uuid';
+import XCommon from './_common_/common.vue';
+import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store';
 import DeckColumnCore from '@/ui/deck/column-core.vue';
 import XSidebar from '@/ui/_common_/sidebar.vue';
 import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue';
 import { getScrollContainer } from '@/scripts/scroll';
 import * as os from '@/os';
 import { menuDef } from '@/menu';
-import XCommon from './_common_/common.vue';
-import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store';
-import { useRoute } from 'vue-router';
 import { $i } from '@/account';
 import { i18n } from '@/i18n';
+import { mainRouter } from '@/router';
 
 const isMobile = ref(window.innerWidth <= 500);
 window.addEventListener('resize', () => {
@@ -68,7 +72,7 @@ window.addEventListener('resize', () => {
 
 const drawerMenuShowing = ref(false);
 
-const route = useRoute();
+const route = 'TODO';
 watch(route, () => {
 	drawerMenuShowing.value = false;
 });
@@ -98,8 +102,8 @@ const addColumn = async (ev) => {
 	const { canceled, result: column } = await os.select({
 		title: i18n.ts._deck.addColumn,
 		items: columns.map(column => ({
-			value: column, text: i18n.t('_deck._columns.' + column)
-		}))
+			value: column, text: i18n.t('_deck._columns.' + column),
+		})),
 	});
 	if (canceled) return;
 
@@ -119,11 +123,6 @@ const onContextmenu = (ev) => {
 };
 
 provide('shouldSpacerMin', true);
-if (deckStore.state.navWindow) {
-	provide('navHook', (url) => {
-		os.pageWindow(url);
-	});
-}
 
 document.documentElement.style.overflowY = 'hidden';
 document.documentElement.style.scrollBehavior = 'auto';
diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue
index 3c97cd4867..670b4a212b 100644
--- a/packages/client/src/ui/deck/main-column.vue
+++ b/packages/client/src/ui/deck/main-column.vue
@@ -1,33 +1,24 @@
 <template>
-<XColumn v-if="deckStore.state.alwaysShowMainColumn || $route.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
+<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)">
 	<template #header>
-		<template v-if="pageInfo">
-			<i :class="pageInfo.icon"></i>
-			{{ pageInfo.title }}
+		<template v-if="pageMetadata?.value">
+			<i :class="pageMetadata?.value.icon"></i>
+			{{ pageMetadata?.value.title }}
 		</template>
 	</template>
 
-	<MkStickyContainer>
-		<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-		<router-view v-slot="{ Component }">
-			<transition>
-				<keep-alive :include="['MkTimelinePage']">
-					<component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/>
-				</keep-alive>
-			</transition>
-		</router-view>
-	</MkStickyContainer>
+	<RouterView @contextmenu.stop="onContextmenu"/>
 </XColumn>
 </template>
 
 <script lang="ts" setup>
-import { } from 'vue';
+import { ComputedRef, provide } from 'vue';
 import XColumn from './column.vue';
 import { deckStore, Column } from '@/ui/deck/deck-store';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
-import { router } from '@/router';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
 defineProps<{
 	column: Column;
@@ -38,14 +29,13 @@ const emit = defineEmits<{
 	(ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void;
 }>();
 
-let pageInfo = $ref<Record<string, any> | null>(null);
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+});
 
-function changePage(page) {
-	if (page == null) return;
-	if (page[symbols.PAGE_INFO]) {
-		pageInfo = page[symbols.PAGE_INFO];
-	}
-}
 /*
 function back() {
 	history.back();
@@ -72,7 +62,7 @@ function onContextmenu(ev: MouseEvent) {
 		text: i18n.ts.openInWindow,
 		action: () => {
 			os.pageWindow(path);
-		}
+		},
 	}], ev);
 }
 </script>
diff --git a/packages/client/src/ui/desktop.vue b/packages/client/src/ui/desktop.vue
deleted file mode 100644
index 17783c58e3..0000000000
--- a/packages/client/src/ui/desktop.vue
+++ /dev/null
@@ -1,70 +0,0 @@
-<template>
-<div class="mk-app" :class="{ wallpaper }" @contextmenu.prevent="() => {}">
-	<XSidebar ref="nav" class="sidebar"/>
-
-	<XCommon/>
-</div>
-</template>
-
-<script lang="ts">
-import { defineComponent } from 'vue';
-import { host } from '@/config';
-import { search } from '@/scripts/search';
-import XCommon from './_common_/common.vue';
-import * as os from '@/os';
-import XSidebar from '@/ui/_common_/sidebar.vue';
-import { menuDef } from '@/menu';
-import { ColdDeviceStorage } from '@/store';
-
-export default defineComponent({
-	components: {
-		XCommon,
-		XSidebar
-	},
-
-	provide() {
-		return {
-			navHook: (url) => {
-				os.pageWindow(url);
-			}
-		};
-	},
-
-	data() {
-		return {
-			host: host,
-			menuDef: menuDef,
-			wallpaper: localStorage.getItem('wallpaper') != null,
-		};
-	},
-
-	computed: {
-		menu(): string[] {
-			return this.$store.state.menu;
-		},
-	},
-
-	created() {
-		if (window.innerWidth < 1024) {
-			localStorage.setItem('ui', 'default');
-			location.reload();
-		}
-	},
-
-	methods: {
-		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
-		},
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.mk-app {
-	height: 100vh;
-	width: 100vw;
-}
-</style>
-
-<style lang="scss">
-</style>
diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue
index a5ff7a6255..41d59342bd 100644
--- a/packages/client/src/ui/universal.vue
+++ b/packages/client/src/ui/universal.vue
@@ -2,26 +2,15 @@
 <div class="dkgtipfy" :class="{ wallpaper }">
 	<XSidebar v-if="!isMobile" class="sidebar"/>
 
-	<div class="contents" :style="{ background: pageInfo?.bg }" @contextmenu.stop="onContextmenu">
+	<div class="contents" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu">
 		<main>
 			<div class="content">
-				<MkStickyContainer>
-					<template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
-					<router-view v-slot="{ Component }">
-						<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-							<keep-alive :include="['MkTimelinePage']">
-								<component :is="Component" :ref="changePage"/>
-							</keep-alive>
-						</transition>
-					</router-view>
-				</MkStickyContainer>
+				<RouterView/>
 			</div>
 			<div class="spacer"></div>
 		</main>
 	</div>
 
-	<XSideView v-if="isDesktop" ref="sideEl" class="side"/>
-
 	<div v-if="isDesktop" ref="widgetsEl" class="widgets">
 		<XWidgets @mounted="attachSticky"/>
 	</div>
@@ -30,14 +19,15 @@
 
 	<div v-if="isMobile" class="buttons">
 		<button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
-		<button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button>
-		<button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
+		<button class="button home _button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i class="fas fa-home"></i></button>
+		<button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
 		<button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
 		<button class="button post _button" @click="os.post()"><i class="fas fa-pencil-alt"></i></button>
 	</div>
 
 	<transition :name="$store.state.animation ? 'menuDrawer-back' : ''">
-		<div v-if="drawerMenuShowing"
+		<div
+			v-if="drawerMenuShowing"
 			class="menuDrawer-back _modalBg"
 			@click="drawerMenuShowing = false"
 			@touchstart.passive="drawerMenuShowing = false"
@@ -49,7 +39,8 @@
 	</transition>
 
 	<transition :name="$store.state.animation ? 'widgetsDrawer-back' : ''">
-		<div v-if="widgetsShowing"
+		<div
+			v-if="widgetsShowing"
 			class="widgetsDrawer-back _modalBg"
 			@click="widgetsShowing = false"
 			@touchstart.passive="widgetsShowing = false"
@@ -65,19 +56,19 @@
 </template>
 
 <script lang="ts" setup>
-import { defineAsyncComponent, provide, onMounted, computed, ref, watch } from 'vue';
+import { defineAsyncComponent, provide, onMounted, computed, ref, watch, ComputedRef } from 'vue';
+import XCommon from './_common_/common.vue';
 import { instanceName } from '@/config';
 import { StickySidebar } from '@/scripts/sticky-sidebar';
 import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue';
-import XCommon from './_common_/common.vue';
-import XSideView from './classic.side.vue';
 import * as os from '@/os';
-import * as symbols from '@/symbols';
 import { defaultStore } from '@/store';
 import { menuDef } from '@/menu';
-import { useRoute } from 'vue-router';
 import { i18n } from '@/i18n';
 import { $i } from '@/account';
+import { Router } from '@/nirax';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
 const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/sidebar.vue'));
 
@@ -90,15 +81,18 @@ window.addEventListener('resize', () => {
 	isMobile.value = window.innerWidth <= MOBILE_THRESHOLD;
 });
 
-const pageInfo = ref();
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
 const widgetsEl = $ref<HTMLElement>();
-const widgetsShowing = ref(false);
-
-let sideEl = $ref<InstanceType<typeof XSideView>>();
-
-provide('sideViewHook', isDesktop.value ? (url) => {
-	sideEl.navigate(url);
-} : null);
+const widgetsShowing = $ref(false);
+
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	console.log(info);
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
 
 const menuIndicated = computed(() => {
 	for (const def in menuDef) {
@@ -110,8 +104,7 @@ const menuIndicated = computed(() => {
 
 const drawerMenuShowing = ref(false);
 
-const route = useRoute();
-watch(route, () => {
+mainRouter.on('change', () => {
 	drawerMenuShowing.value = false;
 });
 
@@ -120,13 +113,13 @@ document.documentElement.style.overflowY = 'scroll';
 if (defaultStore.state.widgets.length === 0) {
 	defaultStore.set('widgets', [{
 		name: 'calendar',
-		id: 'a', place: 'right', data: {}
+		id: 'a', place: 'right', data: {},
 	}, {
 		name: 'notifications',
-		id: 'b', place: 'right', data: {}
+		id: 'b', place: 'right', data: {},
 	}, {
 		name: 'trends',
-		id: 'c', place: 'right', data: {}
+		id: 'c', place: 'right', data: {},
 	}]);
 }
 
@@ -138,14 +131,6 @@ onMounted(() => {
 	}
 });
 
-const changePage = (page) => {
-	if (page == null) return;
-	if (page[symbols.PAGE_INFO]) {
-		pageInfo.value = page[symbols.PAGE_INFO];
-		document.title = `${pageInfo.value.title} | ${instanceName}`;
-	}
-};
-
 const onContextmenu = (ev) => {
 	const isLink = (el: HTMLElement) => {
 		if (el.tagName === 'A') return true;
@@ -156,22 +141,16 @@ const onContextmenu = (ev) => {
 	if (isLink(ev.target)) return;
 	if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
 	if (window.getSelection()?.toString() !== '') return;
-	const path = route.path;
+	const path = mainRouter.getCurrentPath();
 	os.contextMenu([{
 		type: 'label',
 		text: path,
-	}, {
-		icon: 'fas fa-columns',
-		text: i18n.ts.openInSideView,
-		action: () => {
-			sideEl.navigate(path);
-		}
 	}, {
 		icon: 'fas fa-window-maximize',
 		text: i18n.ts.openInWindow,
 		action: () => {
 			os.pageWindow(path);
-		}
+		},
 	}], ev);
 };
 
@@ -186,10 +165,6 @@ function top() {
 	window.scroll({ top: 0, behavior: 'smooth' });
 }
 
-function onTransition() {
-	if (window._scroll) window._scroll();
-}
-
 const wallpaper = localStorage.getItem('wallpaper') != null;
 </script>
 
@@ -274,12 +249,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null;
 		}
 	}
 
-	> .side {
-		min-width: 370px;
-		max-width: 370px;
-		border-left: solid 0.5px var(--divider);
-	}
-
 	> .widgets {
 		padding: 0 var(--margin);
 		border-left: solid 0.5px var(--divider);
diff --git a/packages/client/src/ui/visitor/a.vue b/packages/client/src/ui/visitor/a.vue
index af77f9e970..e98247cbb1 100644
--- a/packages/client/src/ui/visitor/a.vue
+++ b/packages/client/src/ui/visitor/a.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="mk-app">
-	<div v-if="$route.path === '/'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+	<div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
 		<div>
 			<h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
 			<div v-if="meta" class="about">
@@ -20,15 +20,11 @@
 
 	<div class="main">
 		<div ref="contents" class="contents" :class="{ wallpaper }">
-			<header v-show="$route.path !== '/'" ref="header" class="header">
+			<header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header">
 				<XHeader :info="pageInfo"/>
 			</header>
 			<main ref="main">
-				<router-view v-slot="{ Component }">
-					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-						<component :is="Component" :ref="changePage"/>
-					</transition>
-				</router-view>
+				<RouterView/>
 			</main>
 			<div class="powered-by">
 				<b><MkA to="/">{{ host }}</MkA></b>
@@ -41,14 +37,14 @@
 
 <script lang="ts">
 import { defineComponent, defineAsyncComponent } from 'vue';
+import XHeader from './header.vue';
 import { host, instanceName } from '@/config';
 import { search } from '@/scripts/search';
 import * as os from '@/os';
 import MkPagination from '@/components/ui/pagination.vue';
 import MkButton from '@/components/ui/button.vue';
-import XHeader from './header.vue';
 import { ColdDeviceStorage } from '@/store';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
 
 const DESKTOP_THRESHOLD = 1100;
 
@@ -70,6 +66,7 @@ export default defineComponent({
 				endpoint: 'announcements',
 				limit: 10,
 			},
+			mainRouter,
 			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
 		};
 	},
@@ -82,7 +79,7 @@ export default defineComponent({
 					this.$store.set('darkMode', !this.$store.state.darkMode);
 				},
 				's': search,
-				'h|/': this.help
+				'h|/': this.help,
 			};
 		},
 	},
@@ -120,13 +117,9 @@ export default defineComponent({
 		},
 
 		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
-		},
-
-		onTransition() {
-			if (window._scroll) window._scroll();
+			window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank');
 		},
-	}
+	},
 });
 </script>
 
diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue
index c9c0a1f72e..28933f272a 100644
--- a/packages/client/src/ui/visitor/b.vue
+++ b/packages/client/src/ui/visitor/b.vue
@@ -12,11 +12,7 @@
 		<div class="contents">
 			<XHeader v-if="!root" class="header" :info="pageInfo"/>
 			<main>
-				<router-view v-slot="{ Component }">
-					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-						<component :is="Component" :ref="changePage"/>
-					</transition>
-				</router-view>
+				<RouterView/>
 			</main>
 			<div v-if="!root" class="powered-by">
 				<b><MkA to="/">{{ host }}</MkA></b>
@@ -26,7 +22,8 @@
 	</div>
 
 	<transition :name="$store.state.animation ? 'tray-back' : ''">
-		<div v-if="showMenu"
+		<div
+			v-if="showMenu"
 			class="menu-back _modalBg"
 			@click="showMenu = false"
 			@touchstart.passive="showMenu = false"
@@ -48,8 +45,10 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
+<script lang="ts" setup>
+import { ComputedRef, onMounted, provide } from 'vue';
+import XHeader from './header.vue';
+import XKanban from './kanban.vue';
 import { host, instanceName } from '@/config';
 import { search } from '@/scripts/search';
 import * as os from '@/os';
@@ -57,101 +56,64 @@ import MkPagination from '@/components/ui/pagination.vue';
 import XSigninDialog from '@/components/signin-dialog.vue';
 import XSignupDialog from '@/components/signup-dialog.vue';
 import MkButton from '@/components/ui/button.vue';
-import XHeader from './header.vue';
-import XKanban from './kanban.vue';
-import { ColdDeviceStorage } from '@/store';
-import * as symbols from '@/symbols';
+import { ColdDeviceStorage, defaultStore } from '@/store';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 
 const DESKTOP_THRESHOLD = 1100;
 
-export default defineComponent({
-	components: {
-		XHeader,
-		XKanban,
-		MkPagination,
-		MkButton,
-	},
-
-	data() {
-		return {
-			host,
-			instanceName,
-			pageInfo: null,
-			meta: null,
-			showMenu: false,
-			narrow: window.innerWidth < 1280,
-			announcements: {
-				endpoint: 'announcements',
-				limit: 10,
-			},
-			isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
-		};
-	},
-
-	computed: {
-		keymap(): any {
-			return {
-				'd': () => {
-					if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
-					this.$store.set('darkMode', !this.$store.state.darkMode);
-				},
-				's': search,
-				'h|/': this.help
-			};
-		},
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
 
-		root(): boolean {
-			return this.$route.path === '/';
-		},
-	},
-
-	created() {
-		//document.documentElement.style.overflowY = 'scroll';
-
-		os.api('meta', { detail: true }).then(meta => {
-			this.meta = meta;
-		});
-	},
-
-	mounted() {
-		if (!this.isDesktop) {
-			window.addEventListener('resize', () => {
-				if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
-			}, { passive: true });
-		}
-	},
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
+	}
+});
 
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
+const announcements = {
+	endpoint: 'announcements',
+	limit: 10,
+};
+let showMenu = $ref(false);
+let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
+let narrow = $ref(window.innerWidth < 1280);
+let meta = $ref();
+
+const keymap = $computed(() => {
+	return {
+		'd': () => {
+			if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
+			defaultStore.set('darkMode', !defaultStore.state.darkMode);
 		},
+		's': search,
+	};
+});
 
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
+const root = $computed(() => mainRouter.currentRoute.value.name === 'index');
 
-		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
-		},
+os.api('meta', { detail: true }).then(res => {
+	meta = res;
+});
 
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
+function signin() {
+	os.popup(XSigninDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
 
-		signin() {
-			os.popup(XSigninDialog, {
-				autoSet: true
-			}, {}, 'closed');
-		},
+function signup() {
+	os.popup(XSignupDialog, {
+		autoSet: true,
+	}, {}, 'closed');
+}
 
-		signup() {
-			os.popup(XSignupDialog, {
-				autoSet: true
-			}, {}, 'closed');
-		}
+onMounted(() => {
+	if (!isDesktop) {
+		window.addEventListener('resize', () => {
+			if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true;
+		}, { passive: true });
 	}
 });
 </script>
diff --git a/packages/client/src/ui/zen.vue b/packages/client/src/ui/zen.vue
index a7234f729b..c915f82428 100644
--- a/packages/client/src/ui/zen.vue
+++ b/packages/client/src/ui/zen.vue
@@ -1,106 +1,35 @@
 <template>
 <div class="mk-app">
-	<div class="contents">
-		<header class="header">
-			<MkHeader :info="pageInfo"/>
-		</header>
-		<main ref="main">
-			<div class="content">
-				<router-view v-slot="{ Component }">
-					<transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
-						<keep-alive :include="['MkTimelinePage']">
-							<component :is="Component" :ref="changePage"/>
-						</keep-alive>
-					</transition>
-				</router-view>
-			</div>
-		</main>
-	</div>
+	<RouterView/>
 
 	<XCommon/>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, defineAsyncComponent } from 'vue';
-import { host } from '@/config';
+<script lang="ts" setup>
+import { provide, ComputedRef } from 'vue';
 import XCommon from './_common_/common.vue';
-import * as symbols from '@/symbols';
+import { mainRouter } from '@/router';
+import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+import { instanceName } from '@/config';
 
-export default defineComponent({
-	components: {
-		XCommon,
-	},
+let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
 
-	data() {
-		return {
-			host: host,
-			pageInfo: null,
-		};
-	},
-
-	created() {
-		document.documentElement.style.overflowY = 'scroll';
-	},
-
-	methods: {
-		changePage(page) {
-			if (page == null) return;
-			if (page[symbols.PAGE_INFO]) {
-				this.pageInfo = page[symbols.PAGE_INFO];
-			}
-		},
-
-		top() {
-			window.scroll({ top: 0, behavior: 'smooth' });
-		},
-
-		help() {
-			window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
-		},
-
-		onTransition() {
-			if (window._scroll) window._scroll();
-		},
+provide('router', mainRouter);
+provideMetadataReceiver((info) => {
+	pageMetadata = info;
+	if (pageMetadata.value) {
+		document.title = `${pageMetadata.value.title} | ${instanceName}`;
 	}
 });
+
+document.documentElement.style.overflowY = 'scroll';
 </script>
 
 <style lang="scss" scoped>
 .mk-app {
-	$header-height: 52px;
-	$ui-font-size: 1em; // TODO: どこかに集約したい
-
 	// ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
 	min-height: calc(var(--vh, 1vh) * 100);
 	box-sizing: border-box;
-
-	> .contents {
-		padding-top: $header-height;
-
-		> .header {
-			position: fixed;
-			z-index: 1000;
-			top: 0;
-			height: $header-height;
-			width: 100%;
-			line-height: $header-height;
-			text-align: center;
-			//background-color: var(--panel);
-			-webkit-backdrop-filter: var(--blur, blur(32px));
-			backdrop-filter: var(--blur, blur(32px));
-			background-color: var(--header);
-			border-bottom: solid 0.5px var(--divider);
-		}
-
-		> main {
-			> .content {
-				> * {
-					// ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
-					min-height: calc((var(--vh, 1vh) * 100) - #{$header-height});
-				}
-			}
-		}
-	}
 }
 </style>
diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json
index f7320a7251..86109f600a 100644
--- a/packages/client/tsconfig.json
+++ b/packages/client/tsconfig.json
@@ -35,7 +35,8 @@
 		"lib": [
 			"esnext",
 			"dom"
-		]
+		],
+		"jsx": "preserve"
 	},
 	"compileOnSave": false,
 	"include": [
diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts
index af13e646c6..5800cf5021 100644
--- a/packages/client/vite.config.ts
+++ b/packages/client/vite.config.ts
@@ -1,10 +1,10 @@
 import * as fs from 'fs';
 import pluginVue from '@vitejs/plugin-vue';
-import pluginJson5 from './vite.json5';
 import { defineConfig } from 'vite';
 
 import locales from '../../locales';
 import meta from '../../package.json';
+import pluginJson5 from './vite.json5';
 
 const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
 
@@ -57,7 +57,7 @@ export default defineConfig(({ command, mode }) => {
 				},
 				output: {
 					manualChunks: {
-						vue: ['vue', 'vue-router'],
+						vue: ['vue'],
 					},
 				},
 			},
@@ -68,5 +68,5 @@ export default defineConfig(({ command, mode }) => {
 			sourcemap: process.env.NODE_ENV !== 'production',
 			reportCompressedSize: false,
 		},
-	}
+	};
 });
diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock
index 796c72304a..0dc898800d 100644
--- a/packages/client/yarn.lock
+++ b/packages/client/yarn.lock
@@ -647,11 +647,6 @@
     "@vue/compiler-dom" "3.2.37"
     "@vue/shared" "3.2.37"
 
-"@vue/devtools-api@^6.0.0":
-  version "6.0.12"
-  resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.12.tgz#7b57cce215ae9f37a86984633b3aa3d595aa5b46"
-  integrity sha512-iO/4FIezHKXhiDBdKySCvJVh8/mZPxHpiQrTy+PXVqJZgpTPTdHy4q8GXulaY+UKEagdkBb0onxNQZ0LNiqVhw==
-
 "@vue/reactivity-transform@3.2.37":
   version "3.2.37"
   resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz#0caa47c4344df4ae59f5a05dde2a8758829f8eca"
@@ -4232,13 +4227,6 @@ vue-prism-editor@2.0.0-alpha.2:
   resolved "https://registry.yarnpkg.com/vue-prism-editor/-/vue-prism-editor-2.0.0-alpha.2.tgz#aa53a88efaaed628027cbb282c2b1d37fc7c5c69"
   integrity sha512-Gu42ba9nosrE+gJpnAEuEkDMqG9zSUysIR8SdXUw8MQKDjBnnNR9lHC18uOr/ICz7yrA/5c7jHJr9lpElODC7w==
 
-vue-router@4.0.16:
-  version "4.0.16"
-  resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.0.16.tgz#9477beeeef36e80e04d041a1738801a55e6e862e"
-  integrity sha512-JcO7cb8QJLBWE+DfxGUL3xUDOae/8nhM1KVdnudadTAORbuxIC/xAydC5Zr/VLHUDQi1ppuTF5/rjBGzgzrJNA==
-  dependencies:
-    "@vue/devtools-api" "^6.0.0"
-
 vue@3.2.37:
   version "3.2.37"
   resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e"
-- 
cgit v1.2.3-freya


From e393ab6044074a00c1158881effaf2b35702d5ab Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 3 Jul 2022 16:17:31 +0900
Subject: fix(client): style tweak for ios

---
 .../client/src/components/drive-file-thumbnail.vue |  2 +-
 packages/client/src/components/form/range.vue      |  2 +-
 packages/client/src/components/marquee.vue         |  2 +-
 packages/client/src/components/note-preview.vue    |  2 +-
 packages/client/src/components/note-simple.vue     |  2 +-
 packages/client/src/components/note.vue            |  2 +-
 packages/client/src/components/tag-cloud.vue       |  2 +-
 packages/client/src/components/toast.vue           |  2 +-
 packages/client/src/components/ui/button.vue       |  2 +-
 packages/client/src/components/ui/container.vue    | 25 +++++++++++-----------
 packages/client/src/components/ui/modal.vue        |  2 +-
 packages/client/src/pages/about-misskey.vue        |  2 +-
 packages/client/src/pages/about.vue                |  2 +-
 packages/client/src/pages/admin/overview.vue       |  4 ++--
 packages/client/src/pages/antenna-timeline.vue     |  2 +-
 packages/client/src/pages/settings/profile.vue     |  2 +-
 packages/client/src/pages/settings/theme.vue       |  2 +-
 packages/client/src/pages/timeline.vue             |  2 +-
 packages/client/src/pages/user-list-timeline.vue   |  2 +-
 packages/client/src/pages/user/index.photos.vue    |  2 +-
 packages/client/src/pages/welcome.entrance.a.vue   |  2 +-
 packages/client/src/style.scss                     |  2 +-
 packages/client/src/ui/_common_/statusbars.vue     |  2 +-
 packages/client/src/ui/classic.vue                 |  2 +-
 24 files changed, 37 insertions(+), 36 deletions(-)

(limited to 'packages/client/src/components/note.vue')

diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue
index b346585cec..16c77f7267 100644
--- a/packages/client/src/components/drive-file-thumbnail.vue
+++ b/packages/client/src/components/drive-file-thumbnail.vue
@@ -59,7 +59,7 @@ const isThumbnailAvailable = computed(() => {
 	display: flex;
 	background: var(--panel);
 	border-radius: 8px;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 
 	> .icon-sub {
 		position: absolute;
diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue
index 387ad26f3d..f87b92183d 100644
--- a/packages/client/src/components/form/range.vue
+++ b/packages/client/src/components/form/range.vue
@@ -189,7 +189,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
 				height: 3px;
 				background: rgba(0, 0, 0, 0.1);
 				border-radius: 999px;
-				overflow: clip;
+				overflow: hidden; overflow: clip;
 
 				> .highlight {
 					position: absolute;
diff --git a/packages/client/src/components/marquee.vue b/packages/client/src/components/marquee.vue
index 4685033517..e7d6f20ed5 100644
--- a/packages/client/src/components/marquee.vue
+++ b/packages/client/src/components/marquee.vue
@@ -76,7 +76,7 @@ export default {
 
 <style lang="scss" module>
 .wrap {
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 }
 .content {
 	display: inline-block;
diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue
index a78b499654..be7214db19 100644
--- a/packages/client/src/components/note-preview.vue
+++ b/packages/client/src/components/note-preview.vue
@@ -27,7 +27,7 @@ const props = defineProps<{
 	display: flex;
 	margin: 0;
 	padding: 0;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 	font-size: 0.95em;
 
 	&.min-width_350px {
diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue
index b813b9a2b9..93c34b6bf4 100644
--- a/packages/client/src/components/note-simple.vue
+++ b/packages/client/src/components/note-simple.vue
@@ -36,7 +36,7 @@ const showContent = $ref(false);
 	display: flex;
 	margin: 0;
 	padding: 0;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 	font-size: 0.95em;
 
 	&.min-width_350px {
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index c2c92f541d..98c5c9a67a 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -297,7 +297,7 @@ function readPromo() {
 	position: relative;
 	transition: box-shadow 0.1s ease;
 	font-size: 1.05em;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 	contain: content;
 
 	// これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
diff --git a/packages/client/src/components/tag-cloud.vue b/packages/client/src/components/tag-cloud.vue
index 13f528ecf7..5ffa7321e4 100644
--- a/packages/client/src/components/tag-cloud.vue
+++ b/packages/client/src/components/tag-cloud.vue
@@ -73,7 +73,7 @@ defineExpose({
 <style lang="scss" scoped>
 .meijqfqm {
 	position: relative;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 	display: grid;
 	place-items: center;
 
diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue
index c9fad64eb6..e0230dccd4 100644
--- a/packages/client/src/components/toast.vue
+++ b/packages/client/src/components/toast.vue
@@ -54,7 +54,7 @@ onMounted(() => {
 		width: min-content;
 		box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
 		border-radius: 8px;
-		overflow: clip;
+		overflow: hidden; overflow: clip;
 		text-align: center;
 		pointer-events: none;
 
diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue
index e6b20d9881..d8052b511c 100644
--- a/packages/client/src/components/ui/button.vue
+++ b/packages/client/src/components/ui/button.vue
@@ -148,7 +148,7 @@ export default defineComponent({
 	text-decoration: none;
 	background: var(--buttonBg);
 	border-radius: 5px;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 	box-sizing: border-box;
 	transition: background 0.1s ease;
 
diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue
index 7c595d8116..e14242827d 100644
--- a/packages/client/src/components/ui/container.vue
+++ b/packages/client/src/components/ui/container.vue
@@ -10,7 +10,8 @@
 			</button>
 		</div>
 	</header>
-	<transition :name="$store.state.animation ? 'container-toggle' : ''"
+	<transition
+		:name="$store.state.animation ? 'container-toggle' : ''"
 		@enter="enter"
 		@after-enter="afterEnter"
 		@leave="leave"
@@ -34,37 +35,37 @@ export default defineComponent({
 		showHeader: {
 			type: Boolean,
 			required: false,
-			default: true
+			default: true,
 		},
 		thin: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		naked: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		foldable: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		expanded: {
 			type: Boolean,
 			required: false,
-			default: true
+			default: true,
 		},
 		scrollable: {
 			type: Boolean,
 			required: false,
-			default: false
+			default: false,
 		},
 		maxHeight: {
 			type: Number,
 			required: false,
-			default: null
+			default: null,
 		},
 	},
 	data() {
@@ -79,12 +80,12 @@ export default defineComponent({
 			const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
 			this.$el.style.minHeight = `${headerHeight}px`;
 			if (showBody) {
-				this.$el.style.flexBasis = `auto`;
+				this.$el.style.flexBasis = 'auto';
 			} else {
 				this.$el.style.flexBasis = `${headerHeight}px`;
 			}
 		}, {
-			immediate: true
+			immediate: true,
 		});
 
 		this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
@@ -124,7 +125,7 @@ export default defineComponent({
 		afterLeave(el) {
 			el.style.height = null;
 		},
-	}
+	},
 });
 </script>
 
@@ -142,7 +143,7 @@ export default defineComponent({
 
 .ukygtjoj {
 	position: relative;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 
 	&.naked {
 		background: transparent !important;
diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue
index d6a29ec4b7..385f6cdb2b 100644
--- a/packages/client/src/components/ui/modal.vue
+++ b/packages/client/src/components/ui/modal.vue
@@ -389,7 +389,7 @@ defineExpose({
 		left: 0;
 		width: 100%;
 		height: 100%;
-		overflow: clip;
+		overflow: hidden; overflow: clip;
 
 		> .content {
 			position: fixed;
diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue
index fd7b5f936d..a80041b5ce 100644
--- a/packages/client/src/pages/about-misskey.vue
+++ b/packages/client/src/pages/about-misskey.vue
@@ -1,7 +1,7 @@
 <template>
 <MkStickyContainer>
 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
-	<div style="overflow: clip;">
+	<div style="overflow: hidden; overflow: clip;">
 		<MkSpacer :content-max="600" :margin-min="20">
 			<div class="_formRoot znqjceqz">
 				<div id="debug"></div>
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
index e482c4ca6d..6241bbbdda 100644
--- a/packages/client/src/pages/about.vue
+++ b/packages/client/src/pages/about.vue
@@ -138,7 +138,7 @@ definePageMetadata(computed(() => ({
 .fwhjspax {
 	text-align: center;
 	border-radius: 10px;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 	background-size: cover;
 	background-position: center center;
 
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
index 316cf04f14..7e085106b9 100644
--- a/packages/client/src/pages/admin/overview.vue
+++ b/packages/client/src/pages/admin/overview.vue
@@ -561,7 +561,7 @@ definePageMetadata({
 				> .body {
 					background: var(--panel);
 					border-radius: var(--radius);
-					overflow: clip;
+					overflow: hidden; overflow: clip;
 				}
 			}
 
@@ -620,7 +620,7 @@ definePageMetadata({
 				> .body {
 					background: var(--panel);
 					border-radius: var(--radius);
-					overflow: clip;
+					overflow: hidden; overflow: clip;
 				}
 			}
 		}
diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue
index 831ced4eac..f843007643 100644
--- a/packages/client/src/pages/antenna-timeline.vue
+++ b/packages/client/src/pages/antenna-timeline.vue
@@ -110,7 +110,7 @@ definePageMetadata(computed(() => antenna ? {
 	> .tl {
 		background: var(--bg);
 		border-radius: var(--radius);
-		overflow: clip;
+		overflow: hidden; overflow: clip;
 	}
 
 	&.min-width_800px {
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
index 6b60148df5..cbc7327233 100644
--- a/packages/client/src/pages/settings/profile.vue
+++ b/packages/client/src/pages/settings/profile.vue
@@ -192,7 +192,7 @@ definePageMetadata({
 	background-size: cover;
 	background-position: center;
 	border-radius: 10px;
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 
 	> .avatar {
 		display: inline-block;
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
index 0ed4abc37e..1bdad3e75a 100644
--- a/packages/client/src/pages/settings/theme.vue
+++ b/packages/client/src/pages/settings/theme.vue
@@ -175,7 +175,7 @@ definePageMetadata({
 		> .toggleWrapper {
 			display: inline-block;
 			text-align: left;
-			overflow: clip;
+			overflow: hidden; overflow: clip;
 			padding: 0 100px;
 
 			input {
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
index 40eb85ff43..8554a9aebc 100644
--- a/packages/client/src/pages/timeline.vue
+++ b/packages/client/src/pages/timeline.vue
@@ -177,7 +177,7 @@ definePageMetadata(computed(() => ({
 	> .tl {
 		background: var(--bg);
 		border-radius: var(--radius);
-		overflow: clip;
+		overflow: hidden; overflow: clip;
 	}
 }
 </style>
diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue
index 593db1dea7..3fca6f1416 100644
--- a/packages/client/src/pages/user-list-timeline.vue
+++ b/packages/client/src/pages/user-list-timeline.vue
@@ -103,7 +103,7 @@ definePageMetadata(computed(() => list ? {
 	> .tl {
 		background: var(--bg);
 		border-radius: var(--radius);
-		overflow: clip;
+		overflow: hidden; overflow: clip;
 	}
 
 	&.min-width_800px {
diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue
index 79dd1726e1..cedb0e05f3 100644
--- a/packages/client/src/pages/user/index.photos.vue
+++ b/packages/client/src/pages/user/index.photos.vue
@@ -90,7 +90,7 @@ export default defineComponent({
 		> .img {
 			height: 128px;
 			border-radius: 6px;
-			overflow: clip;
+			overflow: hidden; overflow: clip;
 		}
 	}
 
diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue
index 8ff7d9057f..457e38cb23 100644
--- a/packages/client/src/pages/welcome.entrance.a.vue
+++ b/packages/client/src/pages/welcome.entrance.a.vue
@@ -275,7 +275,7 @@ function showMenu(ev) {
 			-webkit-backdrop-filter: var(--blur, blur(15px));
 			backdrop-filter: var(--blur, blur(15px));
 			border-radius: 999px;
-			overflow: clip;
+			overflow: hidden; overflow: clip;
 			width: 800px;
 			padding: 8px 0;
 
diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss
index 94b5dd8cbf..5bd88d5c4a 100644
--- a/packages/client/src/style.scss
+++ b/packages/client/src/style.scss
@@ -260,7 +260,7 @@ hr {
 ._panel {
 	background: var(--panel);
 	border-radius: var(--radius);
-	overflow: clip;
+	overflow: hidden; overflow: clip;
 }
 
 ._block {
diff --git a/packages/client/src/ui/_common_/statusbars.vue b/packages/client/src/ui/_common_/statusbars.vue
index 86d2812f59..11912e141b 100644
--- a/packages/client/src/ui/_common_/statusbars.vue
+++ b/packages/client/src/ui/_common_/statusbars.vue
@@ -52,7 +52,7 @@ const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')
 		width: 100%;
 		line-height: var(--height);
 		height: var(--height);
-		overflow: clip;
+		overflow: hidden; overflow: clip;
 		contain: strict;
 
 		> .name {
diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue
index 412b6db34c..310232aec0 100644
--- a/packages/client/src/ui/classic.vue
+++ b/packages/client/src/ui/classic.vue
@@ -241,7 +241,7 @@ onMounted(() => {
 			border-left: solid 1px var(--divider);
 			border-right: solid 1px var(--divider);
 			border-radius: 0;
-			overflow: clip;
+			overflow: hidden; overflow: clip;
 			--margin: 12px;
 		}
 
-- 
cgit v1.2.3-freya


From a1b8587ab28b4de39bca24ae1ffdd77eb9a0c2f5 Mon Sep 17 00:00:00 2001
From: Johann150 <johann.galle@protonmail.com>
Date: Thu, 7 Jul 2022 14:17:47 +0200
Subject: enhance: show recipients of notes with specified visibility (#8949)

* enhance: reusable visibility component

* rename renote tooltip component

The tooltip that is used for renotes can be used in other cases as well.

* add tooltip for specified recipients

* add changelog entry

* Update visibility.vue

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
---
 CHANGELOG.md                                      |  1 +
 packages/client/src/components/note-detailed.vue  | 29 ++++---------
 packages/client/src/components/note-header.vue    | 16 +-------
 packages/client/src/components/note.vue           | 16 +-------
 packages/client/src/components/renote-button.vue  |  2 +-
 packages/client/src/components/renote.details.vue | 50 -----------------------
 packages/client/src/components/users-tooltip.vue  | 50 +++++++++++++++++++++++
 packages/client/src/components/visibility.vue     | 47 +++++++++++++++++++++
 8 files changed, 112 insertions(+), 99 deletions(-)
 delete mode 100644 packages/client/src/components/renote.details.vue
 create mode 100644 packages/client/src/components/users-tooltip.vue
 create mode 100644 packages/client/src/components/visibility.vue

(limited to 'packages/client/src/components/note.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 128ee4cde9..0b2ffe4805 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ You should also include the user name that made the change.
 - Client: Word mute also checks content warnings @Johann150
 - Client: メニューからページをリロードできるように @syuilo
 - Client: Improve emoji picker performance @syuilo
+- Client: For notes with specified visibility, show recipients when hovering over visibility symbol. @Johann150
 - Client: Make widgets available again on a tablet @syuilo
 - ユーザーにモデレーションメモを残せる機能 @syuilo
 - Make possible to delete an account by admin @syuilo
diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
index ba47bfcd4a..c05ab7fec4 100644
--- a/packages/client/src/components/note-detailed.vue
+++ b/packages/client/src/components/note-detailed.vue
@@ -26,12 +26,7 @@
 				<i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
 				<MkTime :time="note.createdAt"/>
 			</button>
-			<span v-if="note.visibility !== 'public'" class="visibility">
-				<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
-				<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
-				<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
-			</span>
-			<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
+			<MkVisibility :note="note"/>
 		</div>
 	</div>
 	<article class="article" @contextmenu.stop="onContextmenu">
@@ -43,12 +38,9 @@
 						<MkUserName :user="appearNote.user"/>
 					</MkA>
 					<span v-if="appearNote.user.isBot" class="is-bot">bot</span>
-					<span v-if="appearNote.visibility !== 'public'" class="visibility">
-						<i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i>
-						<i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i>
-						<i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i>
-					</span>
-					<span v-if="appearNote.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
+					<div class="info">
+						<MkVisibility :note="appearNote"/>
+					</div>
 				</div>
 				<div class="username"><MkAcct :user="appearNote.user"/></div>
 				<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
@@ -134,6 +126,7 @@ import XPoll from './poll.vue';
 import XRenoteButton from './renote-button.vue';
 import MkUrlPreview from '@/components/url-preview.vue';
 import MkInstanceTicker from '@/components/instance-ticker.vue';
+import MkVisibility from '@/components/visibility.vue';
 import { pleaseLogin } from '@/scripts/please-login';
 import { checkWordMute } from '@/scripts/check-word-mute';
 import { userPage } from '@/filters/user';
@@ -388,14 +381,6 @@ if (appearNote.replyId) {
 					margin-right: 4px;
 				}
 			}
-
-			> .visibility {
-				margin-left: 8px;
-			}
-
-			> .localOnly {
-				margin-left: 8px;
-			}
 		}
 	}
 
@@ -441,6 +426,10 @@ if (appearNote.replyId) {
 						border: solid 0.5px var(--divider);
 						border-radius: 4px;
 					}
+
+					> .info {
+						float: right;
+					}
 				}
 			}
 		}
diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue
index 56a3a37e75..0b05498566 100644
--- a/packages/client/src/components/note-header.vue
+++ b/packages/client/src/components/note-header.vue
@@ -9,12 +9,7 @@
 		<MkA class="created-at" :to="notePage(note)">
 			<MkTime :time="note.createdAt"/>
 		</MkA>
-		<span v-if="note.visibility !== 'public'" class="visibility">
-			<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
-			<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
-			<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
-		</span>
-		<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
+		<MkVisibility :note="note"/>
 	</div>
 </header>
 </template>
@@ -22,6 +17,7 @@
 <script lang="ts" setup>
 import { } from 'vue';
 import * as misskey from 'misskey-js';
+import MkVisibility from '@/components/visibility.vue';
 import { notePage } from '@/filters/note';
 import { userPage } from '@/filters/user';
 
@@ -74,14 +70,6 @@ defineProps<{
 		flex-shrink: 0;
 		margin-left: auto;
 		font-size: 0.9em;
-
-		> .visibility {
-			margin-left: 8px;
-		}
-
-		> .localOnly {
-			margin-left: 8px;
-		}
 	}
 }
 </style>
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
index 98c5c9a67a..b494c70392 100644
--- a/packages/client/src/components/note.vue
+++ b/packages/client/src/components/note.vue
@@ -28,12 +28,7 @@
 				<i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
 				<MkTime :time="note.createdAt"/>
 			</button>
-			<span v-if="note.visibility !== 'public'" class="visibility">
-				<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
-				<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
-				<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
-			</span>
-			<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
+			<MkVisibility :note="note"/>
 		</div>
 	</div>
 	<article class="article" @contextmenu.stop="onContextmenu">
@@ -118,6 +113,7 @@ import XPoll from './poll.vue';
 import XRenoteButton from './renote-button.vue';
 import MkUrlPreview from '@/components/url-preview.vue';
 import MkInstanceTicker from '@/components/instance-ticker.vue';
+import MkVisibility from '@/components/visibility.vue';
 import { pleaseLogin } from '@/scripts/please-login';
 import { focusPrev, focusNext } from '@/scripts/focus';
 import { checkWordMute } from '@/scripts/check-word-mute';
@@ -406,14 +402,6 @@ function readPromo() {
 					margin-right: 4px;
 				}
 			}
-
-			> .visibility {
-				margin-left: 8px;
-			}
-
-			> .localOnly {
-				margin-left: 8px;
-			}
 		}
 	}
 
diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue
index 8d9f08b4c2..3bcbe665bf 100644
--- a/packages/client/src/components/renote-button.vue
+++ b/packages/client/src/components/renote-button.vue
@@ -14,7 +14,7 @@
 
 <script lang="ts">
 import { computed, defineComponent, ref } from 'vue';
-import XDetails from '@/components/renote.details.vue';
+import XDetails from '@/components/users-tooltip.vue';
 import { pleaseLogin } from '@/scripts/please-login';
 import * as os from '@/os';
 import { $i } from '@/account';
diff --git a/packages/client/src/components/renote.details.vue b/packages/client/src/components/renote.details.vue
deleted file mode 100644
index 2df19bcd3f..0000000000
--- a/packages/client/src/components/renote.details.vue
+++ /dev/null
@@ -1,50 +0,0 @@
-<template>
-<MkTooltip ref="tooltip" :target-element="targetElement" :max-width="250" @closed="emit('closed')">
-	<div class="beaffaef">
-		<div v-for="u in users" :key="u.id" class="user">
-			<MkAvatar class="avatar" :user="u"/>
-			<MkUserName class="name" :user="u" :nowrap="true"/>
-		</div>
-		<div v-if="users.length < count" class="omitted">+{{ count - users.length }}</div>
-	</div>
-</MkTooltip>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkTooltip from './ui/tooltip.vue';
-
-const props = defineProps<{
-	users: any[]; // TODO
-	count: number;
-	targetElement: HTMLElement;
-}>();
-
-const emit = defineEmits<{
-	(ev: 'closed'): void;
-}>();
-</script>
-
-<style lang="scss" scoped>
-.beaffaef {
-	font-size: 0.9em;
-	text-align: left;
-
-	> .user {
-		line-height: 24px;
-		white-space: nowrap;
-		overflow: hidden;
-		text-overflow: ellipsis;
-
-		&:not(:last-child) {
-			margin-bottom: 3px;
-		}
-
-		> .avatar {
-			width: 24px;
-			height: 24px;
-			margin-right: 3px;
-		}
-	}
-}
-</style>
diff --git a/packages/client/src/components/users-tooltip.vue b/packages/client/src/components/users-tooltip.vue
new file mode 100644
index 0000000000..2df19bcd3f
--- /dev/null
+++ b/packages/client/src/components/users-tooltip.vue
@@ -0,0 +1,50 @@
+<template>
+<MkTooltip ref="tooltip" :target-element="targetElement" :max-width="250" @closed="emit('closed')">
+	<div class="beaffaef">
+		<div v-for="u in users" :key="u.id" class="user">
+			<MkAvatar class="avatar" :user="u"/>
+			<MkUserName class="name" :user="u" :nowrap="true"/>
+		</div>
+		<div v-if="users.length < count" class="omitted">+{{ count - users.length }}</div>
+	</div>
+</MkTooltip>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkTooltip from './ui/tooltip.vue';
+
+const props = defineProps<{
+	users: any[]; // TODO
+	count: number;
+	targetElement: HTMLElement;
+}>();
+
+const emit = defineEmits<{
+	(ev: 'closed'): void;
+}>();
+</script>
+
+<style lang="scss" scoped>
+.beaffaef {
+	font-size: 0.9em;
+	text-align: left;
+
+	> .user {
+		line-height: 24px;
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+
+		&:not(:last-child) {
+			margin-bottom: 3px;
+		}
+
+		> .avatar {
+			width: 24px;
+			height: 24px;
+			margin-right: 3px;
+		}
+	}
+}
+</style>
diff --git a/packages/client/src/components/visibility.vue b/packages/client/src/components/visibility.vue
new file mode 100644
index 0000000000..b41c950331
--- /dev/null
+++ b/packages/client/src/components/visibility.vue
@@ -0,0 +1,47 @@
+<template>
+<span v-if="note.visibility !== 'public'" :class="$style.visibility">
+	<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+	<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+	<i v-else-if="note.visibility === 'specified'" ref="specified" class="fas fa-envelope"></i>
+</span>
+<span v-if="note.localOnly" :class="$style.localOnly"><i class="fas fa-biohazard"></i></span>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue';
+import XDetails from '@/components/users-tooltip.vue';
+import * as os from '@/os';
+import { useTooltip } from '@/scripts/use-tooltip';
+
+const props = defineProps<{
+	note: {
+		visibility: string;
+		localOnly?: boolean;
+		visibleUserIds?: string[];
+	},
+}>();
+
+const specified = $ref<HTMLElement>();
+
+if (props.note.visibility === 'specified') {
+	useTooltip($$(specified), async (showing) => {
+		const users = await os.api('users/show', {
+			userIds: props.note.visibleUserIds,
+			limit: 10,
+		});
+
+		os.popup(XDetails, {
+			showing,
+			users,
+			count: props.note.visibleUserIds.length,
+			targetElement: specified,
+		}, {}, 'closed');
+	});
+}
+</script>
+
+<style lang="scss" module>
+.visibility, .localOnly {
+	margin-left: 0.5em;
+}
+</style>
-- 
cgit v1.2.3-freya

